Lots of work towards multiple build output

This commit is contained in:
Oliver Walters 2020-11-02 01:24:31 +11:00
parent f1b83f1c17
commit b02c87ea50
16 changed files with 487 additions and 561 deletions

View File

@ -6,13 +6,14 @@ Django Forms for interacting with Build objects
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django import forms
from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField
from django import forms
from .models import Build, BuildItem, BuildOrderAttachment
from stock.models import StockLocation
from stock.models import StockLocation, StockItem
class EditBuildForm(HelperForm):
@ -106,18 +107,18 @@ class UnallocateBuildForm(HelperForm):
class AutoAllocateForm(HelperForm):
""" Form for auto-allocation of stock to a build """
confirm = forms.BooleanField(required=False, help_text=_('Confirm'))
confirm = forms.BooleanField(required=True, help_text=_('Confirm stock allocation'))
output_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput()
# Keep track of which build output we are interested in
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(),
)
class Meta:
model = Build
fields = [
'confirm',
'output_id',
'output',
]
@ -136,20 +137,25 @@ class CompleteBuildForm(HelperForm):
help_text=_('Location of completed parts'),
)
serial_numbers = forms.CharField(
label=_('Serial numbers'),
confirm_incomplete = forms.BooleanField(
required=False,
help_text=_('Enter unique serial numbers (or leave blank)')
help_text=_("Confirm completion with incomplete stock allocation")
)
confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
confirm = forms.BooleanField(required=True, help_text=_('Confirm build completion'))
output = forms.ModelChoiceField(
queryset=StockItem.objects.all(), # Queryset is narrowed in the view
widget=forms.HiddenInput(),
)
class Meta:
model = Build
fields = [
'serial_numbers',
'location',
'confirm'
'output',
'confirm',
'confirm_incomplete',
]

View File

@ -24,7 +24,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus
from InvenTree.helpers import increment, getSetting, normalize
from InvenTree.helpers import ExtractSerialNumbers
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment
@ -183,6 +182,16 @@ class Build(MPTTModel):
blank=True, help_text=_('Extra build notes')
)
@property
def bom_items(self):
"""
Returns the BOM items for the part referenced by this BuildOrder
"""
return self.part.bom_items.all().prefetch_related(
'sub_part'
)
@property
def remaining(self):
"""
@ -195,9 +204,13 @@ class Build(MPTTModel):
def output_count(self):
return self.build_outputs.count()
def getBuildOutputs(self, **kwargs):
def get_build_outputs(self, **kwargs):
"""
Return a list of build outputs
Return a list of build outputs.
kwargs:
complete = (True / False) - If supplied, filter by completed status
in_stock = (True / False) - If supplied, filter by 'in-stock' status
"""
outputs = self.build_outputs
@ -228,7 +241,7 @@ class Build(MPTTModel):
Return all the "completed" build outputs
"""
outputs = self.getBuildOutputs(complete=True)
outputs = self.get_build_outputs(complete=True)
# TODO - Ordering?
@ -240,7 +253,7 @@ class Build(MPTTModel):
Return all the "incomplete" build outputs"
"""
outputs = self.getBuildOutputs(complete=False)
outputs = self.get_build_outputs(complete=False)
# TODO - Order by how "complete" they are?
@ -278,49 +291,6 @@ class Build(MPTTModel):
return new_ref
def createInitialStockItem(self, serial_numbers, user):
"""
Create an initial output StockItem to be completed by this build.
"""
if self.part.trackable:
# Trackable part? Create individual build outputs?
# Serial numbers specified for the build?
if serial_numbers:
serials = ExtractSerialNumbers(serial_numbers, self.quantity)
else:
serials = [None] * self.quantity
for serial in serials:
output = StockModels.StockItem.objects.create(
part=self.part,
location=self.destination,
quantity=1,
batch=self.batch,
serial=serial,
build=self,
is_building=True
)
output.save()
else:
# Create a single build output
output = StockModels.StockItem.objects.create(
part=self.part, # Link to the parent part
location=None, # No location (yet) until it is completed
quantity=self.quantity,
batch='', # The 'batch' code is not set until the item is completed
build=self, # Point back to this build
is_building=True, # Mark this StockItem as building
)
output.save()
# TODO - Add a transaction note to the new StockItem
@transaction.atomic
def cancelBuild(self, user):
""" Mark the Build as CANCELLED
@ -368,59 +338,49 @@ class Build(MPTTModel):
Iterate through each item in the BOM
"""
# Only look at the "untracked" BOM items
# Tracked BOM items must be handled separately
untracked_bom_items = self.part.bom_items.filter(sub_part__trackable=False)
for bom_item in self.bom_items:
for item in untracked_bom_items.prefetch_related('sub_part'):
part = bom_item.sub_part
# How many parts are still required for this build?
#q_required = item.quantity * self.remaining
q_required = self.getUnallocatedQuantity(item.sub_part)
# Skip any parts which are already fully allocated
if self.isPartFullyAllocated(part, output):
continue
# Grab a list of StockItem objects which are "in stock"
stock = StockModels.StockItem.objects.filter(
StockModels.StockItem.IN_STOCK_FILTER
)
# Filter by part reference
stock = stock.filter(part=item.sub_part)
# How many parts are required to complete the output?
required = self.unallocatedQuantity(part, output)
# Exclude any stock items which have already been allocated to this build
allocated = BuildItem.objects.filter(
build=self,
stock_item__part=item.sub_part
)
stock = stock.exclude(
pk__in=[build_item.stock_item.pk for build_item in allocated]
)
# Grab a list of stock items which are available
stock_items = self.availableStockItems(part, output)
# Ensure that the available stock items are in the correct location
if self.take_from is not None:
# Filter for stock that is located downstream of the designated location
stock = stock.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
stock_items = stock_items.filter(location__in=[loc for loc in self.take_from.getUniqueChildren()])
# Only one StockItem to choose from? Default to that one!
if stock.count() == 1:
stock_item = stock[0]
if stock_items.count() == 1:
stock_item = stock_items[0]
# Check that we have not already allocated this stock-item against this build
build_items = BuildItem.objects.filter(build=self, stock_item=stock_item)
# Double check that we have not already allocated this stock-item against this build
build_items = BuildItem.objects.filter(
build=self,
stock_item=stock_item,
install_into=output
)
if len(build_items) > 0:
continue
# Are there any parts available?
# How many items are actually available?
if stock_item.quantity > 0:
# Only take as many as are available
if stock_item.quantity < q_required:
q_required = stock_item.quantity
if stock_item.quantity < required:
required = stock_item.quantity
allocation = {
'stock_item': stock_item,
'quantity': q_required,
'quantity': required,
}
allocations.append(allocation)
@ -472,7 +432,7 @@ class Build(MPTTModel):
output.delete()
@transaction.atomic
def auto_allocate(self, output=None):
def autoAllocate(self, output):
"""
Run auto-allocation routine to allocate StockItems to this Build.
@ -496,151 +456,160 @@ class Build(MPTTModel):
build_item = BuildItem(
build=self,
stock_item=item['stock_item'],
quantity=item['quantity'])
quantity=item['quantity'],
install_into=output,
)
build_item.save()
@transaction.atomic
def completeBuild(self, location, serial_numbers, user):
""" Mark the Build as COMPLETE
def completeBuildOutput(self, output, user, **kwargs):
"""
Complete a particular build output
- Takes allocated items from stock
- Delete pending BuildItem objects
- Remove allocated StockItems
- Mark the output as complete
"""
# Complete the build allocation for each BuildItem
for build_item in self.allocated_stock.all().prefetch_related('stock_item'):
build_item.complete_allocation(user)
# List the allocated BuildItem objects for the given output
allocated_items = output.items_to_install.all()
# Check that the stock-item has been assigned to this build, and remove the builditem from the database
if build_item.stock_item.build_order == self:
build_item.delete()
for build_item in allocated_items:
notes = 'Built {q} on {now}'.format(
q=self.quantity,
now=str(datetime.now().date())
# Complete the allocation of stock for that item
build_item.completeAllocation(user)
# Remove the build item from the database
build_item.delete()
# Ensure that the output is updated correctly
output.build = self
output.is_building = False
output.save()
output.addTransactionNote(
_('Completed build output'),
user,
system=True
)
# Generate the build outputs
if self.part.trackable and serial_numbers:
# Add new serial numbers
for serial in serial_numbers:
item = StockModels.StockItem.objects.create(
part=self.part,
build=self,
location=location,
quantity=1,
serial=serial,
batch=str(self.batch) if self.batch else '',
notes=notes
)
item.save()
else:
# Add stock of the newly created item
item = StockModels.StockItem.objects.create(
part=self.part,
build=self,
location=location,
quantity=self.quantity,
batch=str(self.batch) if self.batch else '',
notes=notes
)
item.save()
# Finally, mark the build as complete
self.completion_date = datetime.now().date()
self.completed_by = user
self.status = BuildStatus.COMPLETE
# Increase the completed quantity for this build
self.completed += output.quantity
self.save()
return True
def isFullyAllocated(self):
def requiredQuantity(self, part, output):
"""
Return True if this build has been fully allocated.
"""
bom_items = self.part.bom_items.all()
for item in bom_items:
part = item.sub_part
if not self.isPartFullyAllocated(part):
return False
return True
def isPartFullyAllocated(self, part):
"""
Check if a given Part is fully allocated for this Build
"""
return self.getAllocatedQuantity(part) >= self.getRequiredQuantity(part)
def getRequiredQuantity(self, part, output=None):
"""
Calculate the quantity of <part> required to make this build.
Get the quantity of a part required to complete the particular build output.
Args:
part: The 'Part' archetype reference
output: A particular build output (StockItem) (or None to specify the entire build)
part: The Part object
output - The particular build output (StockItem)
"""
# Extract the BOM line item from the database
try:
item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id)
q = item.quantity
except PartModels.BomItem.DoesNotExist:
q = 0
bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk)
quantity = bom_item.quantity
except (PartModels.BomItem.DoesNotExist):
quantity = 0
if output:
return q * output.quantity
quantity *= output.quantity
else:
return q * self.remaining
quantity *= self.remaining
def getAllocatedQuantity(self, part, output=None):
return quantity
def allocatedItems(self, part, output):
"""
Calculate the total number of <part> currently allocated to this build.
Return all BuildItem objects which allocate stock of <part> to <output>
Args:
part: The 'Part' archetype reference
output: A particular build output (StockItem) (or None to specify the entire build)
part - The part object
output - Build output (StockItem).
"""
allocations = BuildItem.objects.filter(
build=self.id,
stock_item__part=part.id
build=self,
stock_item__part=part,
install_into=output,
)
# Optionally, filter by the specified build output StockItem
if output is not None:
allocations = allocations.filter(
install_into=output
)
return allocations
def allocatedQuantity(self, part, output):
"""
Return the total quantity of given part allocated to a given build output.
"""
allocations = self.allocatedItems(part, output)
allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0))
return allocated['q']
def getUnallocatedQuantity(self, part, output=None):
def unallocatedQuantity(self, part, output):
"""
Calculate the quantity of <part> which still needs to be allocated to this build.
Args:
part - the part to be tested
output - A particular build output (StockItem) (or None to specify the entire build)
Returns:
The remaining allocated quantity
Return the total unallocated (remaining) quantity of a part against a particular output.
"""
required = self.getRequiredQuantity(part, output=output)
allocated = self.getAllocatedQuantity(part, output=output)
required = self.requiredQuantity(part, output)
allocated = self.allocatedQuantity(part, output)
return max(required - allocated, 0)
def isPartFullyAllocated(self, part, output):
"""
Returns True if the part has been fully allocated to the particular build output
"""
return self.unallocatedQuantity(part, output) == 0
def isFullyAllocated(self, output):
"""
Returns True if the particular build output is fully allocated.
"""
for bom_item in self.bom_items:
part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output):
return False
# All parts must be fully allocated!
return True
def allocatedParts(self, output):
"""
Return a list of parts which have been fully allocated against a particular output
"""
allocated = []
for bom_item in self.bom_items:
part = bom_item.sub_part
if self.isPartFullyAllocated(part, output):
allocated.append(part)
return allocated
def unallocatedParts(self, output):
"""
Return a list of parts which have *not* been fully allocated against a particular output
"""
unallocated = []
for bom_item in self.bom_items:
part = bom_item.sub_part
if not self.isPartFullyAllocated(part, output):
unallocated.append(part)
return unallocated
@property
def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """
@ -658,29 +627,32 @@ class Build(MPTTModel):
return parts
def getAvailableStockItems(self, part=None, output=None):
def availableStockItems(self, part, output):
"""
Return available stock items for the build.
Returns stock items which are available for allocation to this build.
Args:
part - Part object
output - The particular build output
"""
items = StockModels.StockItem.objects.filter(StockModels.StockItem.IN_STOCK_FILTER)
# Grab initial query for items which are "in stock" and match the part
items = StockModels.StockItem.objects.filter(
StockModels.StockItem.IN_STOCK_FILTER
)
if part:
# Filter items which match the given Part
items = items.filter(part=part)
items = items.filter(part=part)
if output:
# Exclude items which are already allocated to the particular build output
# Exclude any items which have already been allocated
allocated = BuildItem.objects.filter(
build=self,
stock_item__part=part,
install_into=output,
)
to_exclude = BuildItem.objects.filter(
build=self,
stock_item__part=part,
install_into=output
)
items = items.exclude(
id__in=[item.stock_item.id for item in to_exclude.all()]
)
items = items.exclude(
id__in=[item.stock_item.id for item in allocated.all()]
)
# Limit query to stock items which are "downstream" of the source location
if self.take_from is not None:
@ -690,16 +662,6 @@ class Build(MPTTModel):
return items
@property
def can_build(self):
""" Return true if there are enough parts to supply build """
for item in self.required_parts:
if item['part'].total_stock < item['quantity']:
return False
return True
@property
def is_active(self):
""" Is this build active? An active build is either:
@ -807,31 +769,23 @@ class BuildItem(models.Model):
errors['quantity'] = _('Allocation quantity must be greater than zero')
# Quantity must be 1 for serialized stock
if self.stock_item.serial and not self.quantity == 1:
if self.stock_item.serialized and not self.quantity == 1:
errors['quantity'] = _('Quantity must be 1 for serialized stock')
# Part reference must match between output stock item and built part
if self.install_into is not None:
if not self.install_into.part == self.build.part:
errors['install_into'] = _('Part reference differs between build and build output')
# A trackable StockItem *must* point to a build output
if self.stock_item.part.trackable and self.install_into is None:
errors['install_into'] = _('Trackable BuildItem must reference a build output')
# A non-trackable StockItem *must not* point to a build output
if not self.stock_item.part.trackable and self.install_into is not None:
errors['install_into'] = _('Non-trackable BuildItem must not reference a build output')
except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
pass
if len(errors) > 0:
raise ValidationError(errors)
def complete_allocation(self, user):
@transaction.atomic
def completeAllocation(self, user):
"""
Complete the allocation of this BuildItem into the output stock item.
# TODO : This required much reworking!!
- If the referenced part is trackable, the stock item will be *installed* into the build output
- If the referenced part is *not* trackable, the stock item will be removed from stock
"""
item = self.stock_item
@ -843,10 +797,13 @@ class BuildItem(models.Model):
self.stock_item = item
self.save()
# TODO - If the item__part object is not trackable, delete the stock item here
item.build_order = self.build
item.save()
if item.part.trackable:
# If the part is trackable, install into the build output
item.belongs_to = self.install_into
item.save()
else:
# Part is *not* trackable, so just delete it
item.delete()
build = models.ForeignKey(
Build,

View File

@ -11,6 +11,9 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %}
<div class='btn-group' role='group'>
</div>
<h4>{% trans "Incomplete Build Ouputs" %}</h4>
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
@ -19,20 +22,17 @@ InvenTree | Allocate Parts
{% endfor %}
</div>
<hr>
<h4>{% trans "Assigned Stock" %}</h4>
<div id='build-item-toolbar'>
{% if build.status == BuildStatus.PENDING %}
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'><span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}</button>
<button class='btn btn-primary' type='button' id='btn-allocate' title='{% trans "Automatically allocate stock" %}'><span class='fas fa-magic'></span> {% trans "Auto Allocate" %}</button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='Unallocate Stock'><span class='fas fa-minus-circle'></span> {% trans "Unallocate" %}</button>
<!---
<div id='build-item-toolbar'>
{% if build.status == BuildStatus.PENDING %}
<div class='btn-group'>
<button class='btn btn-primary' type='button' id='btn-order-parts' title='Order Parts'><span class='fas fa-shopping-cart'></span> {% trans "Order Parts" %}</button>
<button class='btn btn-primary' type='button' id='btn-allocate' title='{% trans "Automatically allocate stock" %}'><span class='fas fa-magic'></span> {% trans "Auto Allocate" %}</button>
<button class='btn btn-danger' type='button' id='btn-unallocate' title='Unallocate Stock'><span class='fas fa-minus-circle'></span> {% trans "Unallocate" %}</button>
</div>
{% endif %}
</div>
{% endif %}
</div>
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table>
-->
{% endblock %}
@ -46,14 +46,6 @@ InvenTree | Allocate Parts
part: {{ build.part.pk }},
};
loadBuildOutputAllocationTable(
buildInfo,
null,
{
table: '#build-item-list',
}
);
{% for item in build.incomplete_outputs %}
// Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {},

View File

@ -6,19 +6,10 @@
{{ block.super }}
<div class='alert alert-block alert-info'>
<b>{% trans "Automatically Allocate Stock" %}</b><br>
{% trans "Where the following conditions are met, stock will be automatically allocated to this build" %}:<br>
<hr>
{% trans "For each part in the BOM, the following tests are performed" %}:<br>
<ul>
<li>{% trans "The part is not marked as trackable" %}</li>
<li>{% trans "Only single stock items exists" %}</li>
<li>{% trans "The stock item is not already allocated to this build" %}</li>
</ul>
<b>{% trans "Automatically Allocate Stock" %}</b><br>
{% trans "The following stock items will be allocated to the specified build output" %}
</div>
{% if allocations %}
<table class='table table-striped table-condensed'>
<tr>
<th></th>

View File

@ -1,36 +1,47 @@
{% extends "modal_form.html" %}
{% load inventree_extras %}
{% load i18n %}
{% block pre_form_content %}
<h4>{% trans "Build" %} - {{ build }}</h4>
{% if build.isFullyAllocated %}
{% if fully_allocated %}
<div class='alert alert-block alert-info'>
<h4>{% trans "Build order allocation is complete" %}</h4>
<h4>{% trans "Stock allocation is complete" %}</h4>
</div>
{% else %}
<div class='alert alert-block alert-danger'>
<h4>{% trans "Warning: Build order allocation is not complete" %}</h4>
{% trans "Build Order has not been fully allocated. Ensure that all Stock Items have been allocated to the Build" %}
<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='alert alert-block alert-success'>
<h4>{% trans "The following actions will be performed:" %}</h4>
<ul>
<li>{% trans "Remove allocated items from stock" %}</li>
<li>{% trans "Add completed items to stock" %}</li>
</ul>
</div>
<div class='panel panel-default'>
<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 %}
{{ build.quantity }} x {{ build.part.full_name }}
{% decimal output.quantity %} x {{ output.part.full_name }}
</div>
</div>

View File

@ -114,19 +114,6 @@
{% endif %}
</td>
</tr>
{% if build.is_active %}
<tr>
<td></td>
<td>{% trans "Enough Parts?" %}</td>
<td>
{% if build.can_build %}
{% trans "Yes" %}
{% else %}
{% trans "No" %}
{% endif %}
</td>
</tr>
{% endif %}
{% if build.completion_date %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>

View File

@ -82,12 +82,10 @@ class BuildTest(TestCase):
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_1), 100)
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
self.assertTrue(self.build.can_build)
self.assertFalse(self.build.is_complete)
# Delete some stock and see if the build can still be completed
self.stock_2_1.delete()
self.assertFalse(self.build.can_build)
def test_build_item_clean(self):
# Ensure that dodgy BuildItem objects cannot be created

View File

@ -95,6 +95,9 @@ class BuildAutoAllocate(AjaxUpdateView):
role_required = 'build.change'
def get_initial(self):
"""
Initial values for the form.
"""
initials = super().get_initial()
@ -102,62 +105,73 @@ class BuildAutoAllocate(AjaxUpdateView):
output = self.get_param('output')
if output:
initials['output_id'] = output
try:
output = StockItem.objects.get(pk=output)
initials['output'] = output
except (ValueError, StockItem.DoesNotExist):
pass
return initials
def get_context_data(self, *args, **kwargs):
""" Get the context data for form rendering. """
"""
Get the context data for form rendering.
"""
context = {}
output = self.get_form()['output_id'].value()
build = self.get_object()
form = self.get_form()
output_id = form['output'].value()
try:
build = Build.objects.get(id=self.kwargs['pk'])
context['build'] = build
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
if output:
context['output'] = output
context['allocations'] = build.getAutoAllocations(output)
except Build.DoesNotExist:
context['error'] = _('No matching build found')
context['build'] = build
return context
def post(self, request, *args, **kwargs):
""" Handle POST request. Perform auto allocations.
def get_form(self):
- If the form validation passes, perform allocations
- Otherwise, the form is passed back to the client
form = super().get_form()
if form['output'].value():
# Hide the 'output' field
form.fields['output'].widget = HiddenInput()
return form
def validate(self, build, form, **kwargs):
output = form.cleaned_data.get('output', None)
if not output:
form.add_error(None, _('Build output must be specified'))
def post_save(self, build, form, **kwargs):
"""
Once the form has been validated,
perform auto-allocations
"""
build = self.get_object()
form = self.get_form()
output = form.cleaned_data.get('output', None)
confirm = request.POST.get('confirm', False)
build.autoAllocate(output)
output = None
output_id = request.POST.get('output_id', None)
if output_id:
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
pass
valid = False
if confirm:
build.auto_allocate(output)
valid = True
else:
form.add_error('confirm', _('Confirm stock allocation'))
form.add_error(None, _('Check the confirmation box at the bottom of the list'))
data = {
'form_valid': valid,
def get_data(self):
return {
'success': _('Allocated stock to build output'),
}
return self.renderJsonResponse(request, form, data, context=self.get_context_data())
class BuildOutputDelete(AjaxUpdateView):
"""
@ -292,37 +306,60 @@ class BuildComplete(AjaxUpdateView):
model = Build
form_class = forms.CompleteBuildForm
context_object_name = "build"
ajax_form_title = _("Complete Build")
ajax_form_title = _("Complete Build Output")
ajax_template_name = "build/complete.html"
role_required = 'build.change'
def get_form(self):
""" Get the form object.
If the part is trackable, include a field for serial numbers.
"""
build = self.get_object()
form = super().get_form()
if not build.part.trackable:
form.fields.pop('serial_numbers')
else:
# Extract the build output object
output = None
output_id = form['output'].value()
form.field_placeholder['serial_numbers'] = build.part.getSerialNumberString(build.quantity)
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
pass
form.rebuild_layout()
if output:
if build.isFullyAllocated(output):
form.fields['confirm_incomplete'].widget = HiddenInput()
return form
def validate(self, build, form, **kwargs):
data = form.cleaned_data
output = data.get('output', None)
if output:
quantity = data.get('quantity', None)
if quantity and quantity > output.quantity:
form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity'))
if not build.isFullyAllocated(output):
confirm = str2bool(data.get('confirm_incomplete', False))
if not confirm:
form.add_error('confirm_incomplete', _('Confirm completion of incomplete build'))
else:
form.add_error(None, _('Build output must be specified'))
def get_initial(self):
""" Get initial form data for the CompleteBuild form
- If the part being built has a default location, pre-select that location
"""
initials = super(BuildComplete, self).get_initial().copy()
initials = super().get_initial()
build = self.get_object()
if build.part.default_location is not None:
@ -332,94 +369,77 @@ class BuildComplete(AjaxUpdateView):
except StockLocation.DoesNotExist:
pass
output = self.get_param('output', None)
if output:
try:
output = StockItem.objects.get(pk=output)
except (ValueError, StockItem.DoesNotExist):
output = None
# Output has not been supplied? Try to "guess"
if not output:
incomplete = build.get_build_outputs(complete=False)
if incomplete.count() == 1:
output = incomplete[0]
if output is not None:
initials['output'] = output
initials['location'] = build.destination
return initials
def get_context_data(self, **kwargs):
""" Get context data for passing to the rendered form
"""
Get context data for passing to the rendered form
- Build information is required
"""
build = Build.objects.get(id=self.kwargs['pk'])
build = self.get_object()
context = {}
# Build object
context['build'] = build
# Items to be removed from stock
taking = BuildItem.objects.filter(build=build.id)
context['taking'] = taking
return context
def post(self, request, *args, **kwargs):
""" Handle POST request. Mark the build as COMPLETE
- If the form validation passes, the Build objects completeBuild() method is called
- Otherwise, the form is passed back to the client
"""
build = self.get_object()
form = self.get_form()
confirm = str2bool(request.POST.get('confirm', False))
output = form['output'].value()
loc_id = request.POST.get('location', None)
valid = False
if confirm is False:
form.add_error('confirm', _('Confirm completion of build'))
else:
if output:
try:
location = StockLocation.objects.get(id=loc_id)
valid = True
except (ValueError, StockLocation.DoesNotExist):
form.add_error('location', _('Invalid location selected'))
output = StockItem.objects.get(pk=output)
context['output'] = output
context['fully_allocated'] = build.isFullyAllocated(output)
context['allocated_parts'] = build.allocatedParts(output)
context['unallocated_parts'] = build.unallocatedParts(output)
except (ValueError, StockItem.DoesNotExist):
pass
serials = []
return context
if build.part.trackable:
# A build for a trackable part may optionally specify serial numbers.
def post_save(self, build, form, **kwargs):
sn = request.POST.get('serial_numbers', '')
data = form.cleaned_data
sn = str(sn).strip()
# If the user has specified serial numbers, check they are valid
if len(sn) > 0:
try:
# Exctract a list of provided serial numbers
serials = ExtractSerialNumbers(sn, build.quantity)
existing = build.part.find_conflicting_serial_numbers(serials)
if len(existing) > 0:
exists = ",".join([str(x) for x in existing])
form.add_error('serial_numbers', _('The following serial numbers already exist: ({sn})'.format(sn=exists)))
valid = False
except ValidationError as e:
form.add_error('serial_numbers', e.messages)
valid = False
if valid:
if not build.completeBuild(location, serials, request.user):
form.add_error(None, _('Build could not be completed'))
valid = False
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data, context=self.get_context_data())
location = data.get('location', None)
output = data.get('output', None)
# Complete the build output
build.completeBuildOutput(
output,
self.request.user,
location=location,
)
def get_data(self):
""" Provide feedback data back to the form """
return {
'info': _('Build marked as COMPLETE')
'success': _('Build output completed')
}
@ -588,19 +608,6 @@ class BuildCreate(AjaxCreateView):
'serial_numbers',
_('Serial numbers already exist') + ': ' + msg
)
def post_save(self, **kwargs):
"""
Called immediately after a new Build object is created.
"""
build = kwargs['new_object']
request = kwargs['request']
data = kwargs['data']
serials = data['serial_numbers']
build.createInitialStockItem(serials, request.user)
class BuildUpdate(AjaxUpdateView):
@ -684,11 +691,13 @@ class BuildItemCreate(AjaxCreateView):
return ctx
def validate(self, request, form, data):
def validate(self, build_item, form, **kwargs):
"""
Extra validation steps as required
"""
data = form.cleaned_data
stock_item = data.get('stock_item', None)
quantity = data.get('quantity', None)
@ -702,7 +711,9 @@ class BuildItemCreate(AjaxCreateView):
available = stock_item.unallocated_quantity()
if quantity > available:
form.add_error('stock_item', _('Stock item is over-allocated'))
form.add_error('quantity', _('Avaialabe') + ': ' + str(normalize(available)))
form.add_error('quantity', _('Available') + ': ' + str(normalize(available)))
else:
form.add_error('stock_item', _('Stock item must be selected'))
def get_form(self):
""" Create Form for making / editing new Part object """
@ -758,7 +769,7 @@ class BuildItemCreate(AjaxCreateView):
form.fields['install_into'].widget = HiddenInput()
if self.build and self.part:
available_items = self.build.getAvailableStockItems(part=self.part, output=self.output)
available_items = self.build.availableStockItems(self.part, self.output)
form.fields['stock_item'].queryset = available_items
self.available_stock = form.fields['stock_item'].queryset.all()
@ -819,7 +830,7 @@ class BuildItemCreate(AjaxCreateView):
# Work out how much stock is required
if build and part:
required_quantity = build.getUnallocatedQuantity(part, output=output)
required_quantity = build.unallocatedQuantity(part, output)
else:
required_quantity = None

View File

@ -905,6 +905,18 @@ class Part(MPTTModel):
def has_bom(self):
return self.bom_count > 0
def has_trackable_parts(self):
"""
Return True if any parts linked in the Bill of Materials are trackable.
This is important when building the part.
"""
for bom_item in self.bom_items.all():
if bom_item.sub_part.trackable:
return True
return False
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
@ -1188,7 +1200,7 @@ class Part(MPTTModel):
parameter.save()
@transaction.atomic
def deepCopy(self, other, **kwargs):
def deep_copy(self, other, **kwargs):
""" Duplicates non-field data from another part.
Does not alter the normal fields of this part,
but can be used to copy other data linked by ForeignKey refernce.

View File

@ -99,7 +99,7 @@ class PartTest(TestCase):
self.assertIn(self.R1.name, barcode)
def test_copy(self):
self.R2.deepCopy(self.R1, image=True, bom=True)
self.R2.deep_copy(self.R1, image=True, bom=True)
def test_match_names(self):

View File

@ -360,7 +360,7 @@ class MakePartVariant(AjaxCreateView):
parameters_copy = str2bool(request.POST.get('parameters_copy', False))
# Copy relevent information from the template part
part.deepCopy(part_template, bom=bom_copy, parameters=parameters_copy)
part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
return self.renderJsonResponse(request, form, data, context=context)
@ -473,7 +473,7 @@ class PartDuplicate(AjaxCreateView):
original = self.get_part_to_copy()
if original:
part.deepCopy(original, bom=bom_copy, parameters=parameters_copy)
part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
try:
data['url'] = part.get_absolute_url()

View File

@ -175,7 +175,7 @@ class StockItem(MPTTModel):
if add_note:
# This StockItem is being saved for the first time
self.addTransactionNote(
'Created stock item',
_('Created stock item'),
user,
notes="Created new stock item for part '{p}'".format(p=str(self.part)),
system=True
@ -199,13 +199,7 @@ class StockItem(MPTTModel):
"""
super(StockItem, self).validate_unique(exclude)
# If the part is trackable, either serial number or batch number must be set
if self.part.trackable:
if not self.serial and not self.batch:
msg = _('Serial or batch number must be specified for trackable stock')
raise ValidationError(msg)
# If the serial number is set, make sure it is not a duplicate
if self.serial is not None:
# Query to look for duplicate serial numbers

View File

@ -152,17 +152,7 @@ function loadBomTable(table, options) {
var sub_part = row.sub_part_detail;
if (sub_part.trackable) {
html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
}
if (sub_part.virtual) {
html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
}
if (sub_part.is_template) {
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
}
html += makePartIcons(row.sub_part_detail);
// Display an extra icon if this part is an assembly
if (sub_part.assembly) {
@ -171,10 +161,6 @@ function loadBomTable(table, options) {
html += renderLink(text, `/part/${row.sub_part}/bom/`);
}
if (!sub_part.active) {
html += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
}
return html;
}
}

View File

@ -36,11 +36,8 @@ function makeBuildOutputActionButtons(output, buildInfo) {
/* Generate action buttons for a build output.
*/
var outputId = 'untracked';
if (output) {
outputId = output.pk;
}
var buildId = buildInfo.pk;
var outputId = output.pk;
var panel = `#allocation-panel-${outputId}`;
@ -54,23 +51,19 @@ function makeBuildOutputActionButtons(output, buildInfo) {
var html = `<div class='btn-group float-right' role='group'>`;
// Add a button to "auto allocate" against the build
if (!output) {
html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}',
);
}
html += makeIconButton(
'fa-magic icon-blue', 'button-output-auto', outputId,
'{% trans "Auto-allocate stock items to this output" %}',
);
if (output) {
// Add a button to "complete" the particular build output
html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId,
'{% trans "Complete build output" %}',
{
disabled: true
}
);
}
// Add a button to "complete" the particular build output
html += makeIconButton(
'fa-check icon-green', 'button-output-complete', outputId,
'{% trans "Complete build output" %}',
{
//disabled: true
}
);
// Add a button to "cancel" the particular build output (unallocate)
html += makeIconButton(
@ -78,18 +71,14 @@ function makeBuildOutputActionButtons(output, buildInfo) {
'{% trans "Unallocate stock from build output" %}',
);
if (output) {
// Add a button to "delete" the particular build output
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
// Add a button to "destroy" the particular build output (mark as damaged, scrap)
// TODO
}
// Add a button to "delete" the particular build output
html += makeIconButton(
'fa-trash-alt icon-red', 'button-output-delete', outputId,
'{% trans "Delete build output" %}',
);
// Add a button to "destroy" the particular build output (mark as damaged, scrap)
// TODO
html += '</div>';
@ -103,13 +92,21 @@ function makeBuildOutputActionButtons(output, buildInfo) {
data: {
output: outputId,
},
reload: true,
success: reloadTable,
}
);
});
$(panel).find(`#button-output-complete-${outputId}`).click(function() {
// TODO
launchModalForm(
`/build/${buildId}/complete/`,
{
success: reloadTable,
data: {
output: outputId,
}
}
);
});
$(panel).find(`#button-output-unallocate-${outputId}`).click(function() {
@ -155,18 +152,12 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var outputId = null;
if (output) {
outputId = output.pk;
}
outputId = output.pk;
var table = options.table;
if (options.table == null) {
if (outputId != null) {
table = `#allocation-table-${outputId}`;
} else {
table = `#allocation-table-untracked`;
}
table = `#allocation-table-${outputId}`;
}
function reloadTable() {
@ -177,14 +168,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
function requiredQuantity(row) {
// Return the requied quantity for a given row
if (output) {
// Tracked stock allocated against a particular BuildOutput
return row.quantity * output.quantity;
} else {
// Untrack stock allocated against the build
return row.quantity * (buildInfo.quantity - buildInfo.completed);
}
return row.quantity * output.quantity;
}
function sumAllocations(row) {
@ -300,7 +284,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
queryParams: {
part: partId,
sub_part_detail: true,
sub_part_trackable: outputId != null,
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
@ -357,14 +340,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var allocatedQuantity = sumAllocations(tableRow);
// Is this line item fully allocated?
if (output) {
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) {
allocatedLines += 1;
}
} else {
if (allocatedQuantity >= (tableRow.quantity * (buildInfo.quantity - buildInfo.completed))) {
allocatedLines += 1;
}
if (allocatedQuantity >= (tableRow.quantity * output.quantity)) {
allocatedLines += 1;
}
// Push the updated row back into the main table
@ -506,6 +483,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var html = imageHoverIcon(thumb) + renderLink(name, url);
html += makePartIcons(row.sub_part_detail);
return html;
}
},
@ -547,13 +526,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
var qA = rowA.quantity;
var qB = rowB.quantity;
if (output) {
qA *= output.quantity;
qB *= output.quantity;
} else {
qA *= buildInfo.quantity;
qB *= buildInfo.quantity;
}
qA *= output.quantity;
qB *= output.quantity;
// Handle the case where both numerators are zero
if ((aA == 0) && (aB == 0)) {

View File

@ -61,6 +61,45 @@ function toggleStar(options) {
}
function makePartIcons(part, options={}) {
/* Render a set of icons for the given part.
*/
var html = '';
if (part.trackable) {
html += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
}
if (part.virtual) {
html += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
}
if (part.is_template) {
html += makeIconBadge('fa-clone', '{% trans "Template part" %}');
}
if (part.assembly) {
html += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
}
if (part.starred) {
html += makeIconBadge('fa-star', '{% trans "Starred part" %}');
}
if (part.salable) {
html += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}');
}
if (!part.active) {
html += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
}
return html;
}
function loadPartVariantTable(table, partId, options={}) {
/* Load part variant table
*/
@ -340,40 +379,8 @@ function loadPartTable(table, url, options={}) {
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
if (row.trackable) {
display += makeIconBadge('fa-directions', '{% trans "Trackable part" %}');
}
display += makePartIcons(row);
if (row.virtual) {
display += makeIconBadge('fa-ghost', '{% trans "Virtual part" %}');
}
if (row.is_template) {
display += makeIconBadge('fa-clone', '{% trans "Template part" %}');
}
if (row.assembly) {
display += makeIconBadge('fa-tools', '{% trans "Assembled part" %}');
}
if (row.starred) {
display += makeIconBadge('fa-star', '{% trans "Starred part" %}');
}
if (row.salable) {
display += makeIconBadge('fa-dollar-sign', title='{% trans "Salable part" %}');
}
/*
if (row.component) {
display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;
}
*/
if (!row.active) {
display += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`;
}
return display;
}
});

View File

@ -908,7 +908,7 @@ function loadInstalledInTable(table, options) {
url: "{% url 'api-bom-list' %}",
queryParams: {
part: options.part,
trackable: true,
sub_part_trackable: true,
sub_part_detail: true,
},
showColumns: false,