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 __future__ import unicode_literals
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django import forms
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from django import forms
from .models import Build, BuildItem, BuildOrderAttachment from .models import Build, BuildItem, BuildOrderAttachment
from stock.models import StockLocation
from stock.models import StockLocation, StockItem
class EditBuildForm(HelperForm): class EditBuildForm(HelperForm):
@ -106,18 +107,18 @@ class UnallocateBuildForm(HelperForm):
class AutoAllocateForm(HelperForm): class AutoAllocateForm(HelperForm):
""" Form for auto-allocation of stock to a build """ """ 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( # Keep track of which build output we are interested in
required=False, output = forms.ModelChoiceField(
widget=forms.HiddenInput() queryset=StockItem.objects.all(),
) )
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
'confirm', 'confirm',
'output_id', 'output',
] ]
@ -136,20 +137,25 @@ class CompleteBuildForm(HelperForm):
help_text=_('Location of completed parts'), help_text=_('Location of completed parts'),
) )
serial_numbers = forms.CharField( confirm_incomplete = forms.BooleanField(
label=_('Serial numbers'),
required=False, 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: class Meta:
model = Build model = Build
fields = [ fields = [
'serial_numbers',
'location', '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.status_codes import BuildStatus
from InvenTree.helpers import increment, getSetting, normalize from InvenTree.helpers import increment, getSetting, normalize
from InvenTree.helpers import ExtractSerialNumbers
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
@ -183,6 +182,16 @@ class Build(MPTTModel):
blank=True, help_text=_('Extra build notes') 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 @property
def remaining(self): def remaining(self):
""" """
@ -195,9 +204,13 @@ class Build(MPTTModel):
def output_count(self): def output_count(self):
return self.build_outputs.count() 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 outputs = self.build_outputs
@ -228,7 +241,7 @@ class Build(MPTTModel):
Return all the "completed" build outputs Return all the "completed" build outputs
""" """
outputs = self.getBuildOutputs(complete=True) outputs = self.get_build_outputs(complete=True)
# TODO - Ordering? # TODO - Ordering?
@ -240,7 +253,7 @@ class Build(MPTTModel):
Return all the "incomplete" build outputs" 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? # TODO - Order by how "complete" they are?
@ -278,49 +291,6 @@ class Build(MPTTModel):
return new_ref 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 @transaction.atomic
def cancelBuild(self, user): def cancelBuild(self, user):
""" Mark the Build as CANCELLED """ Mark the Build as CANCELLED
@ -368,59 +338,49 @@ class Build(MPTTModel):
Iterate through each item in the BOM Iterate through each item in the BOM
""" """
# Only look at the "untracked" BOM items for bom_item in self.bom_items:
# Tracked BOM items must be handled separately
untracked_bom_items = self.part.bom_items.filter(sub_part__trackable=False)
for item in untracked_bom_items.prefetch_related('sub_part'): part = bom_item.sub_part
# How many parts are still required for this build? # Skip any parts which are already fully allocated
#q_required = item.quantity * self.remaining if self.isPartFullyAllocated(part, output):
q_required = self.getUnallocatedQuantity(item.sub_part) continue
# Grab a list of StockItem objects which are "in stock" # How many parts are required to complete the output?
stock = StockModels.StockItem.objects.filter( required = self.unallocatedQuantity(part, output)
StockModels.StockItem.IN_STOCK_FILTER
)
# Filter by part reference # Grab a list of stock items which are available
stock = stock.filter(part=item.sub_part) stock_items = self.availableStockItems(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]
)
# 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:
# Filter for stock that is located downstream of the designated location # 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! # Only one StockItem to choose from? Default to that one!
if stock.count() == 1: if stock_items.count() == 1:
stock_item = stock[0] stock_item = stock_items[0]
# Check that we have not already allocated this stock-item against this build # 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) build_items = BuildItem.objects.filter(
build=self,
stock_item=stock_item,
install_into=output
)
if len(build_items) > 0: if len(build_items) > 0:
continue continue
# Are there any parts available? # How many items are actually available?
if stock_item.quantity > 0: if stock_item.quantity > 0:
# Only take as many as are available # Only take as many as are available
if stock_item.quantity < q_required: if stock_item.quantity < required:
q_required = stock_item.quantity required = stock_item.quantity
allocation = { allocation = {
'stock_item': stock_item, 'stock_item': stock_item,
'quantity': q_required, 'quantity': required,
} }
allocations.append(allocation) allocations.append(allocation)
@ -472,7 +432,7 @@ class Build(MPTTModel):
output.delete() output.delete()
@transaction.atomic @transaction.atomic
def auto_allocate(self, output=None): def autoAllocate(self, output):
""" """
Run auto-allocation routine to allocate StockItems to this Build. Run auto-allocation routine to allocate StockItems to this Build.
@ -496,151 +456,160 @@ class Build(MPTTModel):
build_item = BuildItem( build_item = BuildItem(
build=self, build=self,
stock_item=item['stock_item'], stock_item=item['stock_item'],
quantity=item['quantity']) quantity=item['quantity'],
install_into=output,
)
build_item.save() build_item.save()
@transaction.atomic @transaction.atomic
def completeBuild(self, location, serial_numbers, user): def completeBuildOutput(self, output, user, **kwargs):
""" Mark the Build as COMPLETE """
Complete a particular build output
- Takes allocated items from stock - Remove allocated StockItems
- Delete pending BuildItem objects - Mark the output as complete
""" """
# Complete the build allocation for each BuildItem # List the allocated BuildItem objects for the given output
for build_item in self.allocated_stock.all().prefetch_related('stock_item'): allocated_items = output.items_to_install.all()
build_item.complete_allocation(user)
# Check that the stock-item has been assigned to this build, and remove the builditem from the database for build_item in allocated_items:
if build_item.stock_item.build_order == self:
# Complete the allocation of stock for that item
build_item.completeAllocation(user)
# Remove the build item from the database
build_item.delete() build_item.delete()
notes = 'Built {q} on {now}'.format( # Ensure that the output is updated correctly
q=self.quantity, output.build = self
now=str(datetime.now().date()) output.is_building = False
output.save()
output.addTransactionNote(
_('Completed build output'),
user,
system=True
) )
# Generate the build outputs # Increase the completed quantity for this build
if self.part.trackable and serial_numbers: self.completed += output.quantity
# 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
self.save() self.save()
return True def requiredQuantity(self, part, output):
def isFullyAllocated(self):
""" """
Return True if this build has been fully allocated. Get the quantity of a part required to complete the particular build output.
"""
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.
Args: Args:
part: The 'Part' archetype reference part: The Part object
output: A particular build output (StockItem) (or None to specify the entire build) output - The particular build output (StockItem)
""" """
# Extract the BOM line item from the database
try: try:
item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id) bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk)
q = item.quantity quantity = bom_item.quantity
except PartModels.BomItem.DoesNotExist: except (PartModels.BomItem.DoesNotExist):
q = 0 quantity = 0
if output: if output:
return q * output.quantity quantity *= output.quantity
else: 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: Args:
part: The 'Part' archetype reference part - The part object
output: A particular build output (StockItem) (or None to specify the entire build) output - Build output (StockItem).
""" """
allocations = BuildItem.objects.filter( allocations = BuildItem.objects.filter(
build=self.id, build=self,
stock_item__part=part.id stock_item__part=part,
install_into=output,
) )
# Optionally, filter by the specified build output StockItem return allocations
if output is not None:
allocations = allocations.filter( def allocatedQuantity(self, part, output):
install_into=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)) allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0))
return allocated['q'] 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. Return the total unallocated (remaining) quantity of a part against a particular output.
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
""" """
required = self.getRequiredQuantity(part, output=output) required = self.requiredQuantity(part, output)
allocated = self.getAllocatedQuantity(part, output=output) allocated = self.allocatedQuantity(part, output)
return max(required - allocated, 0) 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 @property
def required_parts(self): def required_parts(self):
""" Returns a dict of parts required to build this part (BOM) """ """ Returns a dict of parts required to build this part (BOM) """
@ -658,28 +627,31 @@ class Build(MPTTModel):
return parts 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 any items which have already been allocated
# Exclude items which are already allocated to the particular build output allocated = BuildItem.objects.filter(
to_exclude = BuildItem.objects.filter(
build=self, build=self,
stock_item__part=part, stock_item__part=part,
install_into=output install_into=output,
) )
items = items.exclude( items = items.exclude(
id__in=[item.stock_item.id for item in to_exclude.all()] id__in=[item.stock_item.id for item in allocated.all()]
) )
# Limit query to stock items which are "downstream" of the source location # Limit query to stock items which are "downstream" of the source location
@ -690,16 +662,6 @@ class Build(MPTTModel):
return items 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 @property
def is_active(self): def is_active(self):
""" Is this build active? An active build is either: """ 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') errors['quantity'] = _('Allocation quantity must be greater than zero')
# Quantity must be 1 for serialized stock # 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') 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): except (StockModels.StockItem.DoesNotExist, PartModels.Part.DoesNotExist):
pass pass
if len(errors) > 0: if len(errors) > 0:
raise ValidationError(errors) 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 item = self.stock_item
@ -843,10 +797,13 @@ class BuildItem(models.Model):
self.stock_item = item self.stock_item = item
self.save() self.save()
# TODO - If the item__part object is not trackable, delete the stock item here if item.part.trackable:
# If the part is trackable, install into the build output
item.build_order = self.build item.belongs_to = self.install_into
item.save() item.save()
else:
# Part is *not* trackable, so just delete it
item.delete()
build = models.ForeignKey( build = models.ForeignKey(
Build, Build,

View File

@ -11,6 +11,9 @@ InvenTree | Allocate Parts
{% include "build/tabs.html" with tab='allocate' %} {% include "build/tabs.html" with tab='allocate' %}
<div class='btn-group' role='group'>
</div>
<h4>{% trans "Incomplete Build Ouputs" %}</h4> <h4>{% trans "Incomplete Build Ouputs" %}</h4>
<div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true"> <div class="panel-group" id="build-output-accordion" role="tablist" aria-multiselectable="true">
@ -19,9 +22,7 @@ InvenTree | Allocate Parts
{% endfor %} {% endfor %}
</div> </div>
<hr> <!---
<h4>{% trans "Assigned Stock" %}</h4>
<div id='build-item-toolbar'> <div id='build-item-toolbar'>
{% if build.status == BuildStatus.PENDING %} {% if build.status == BuildStatus.PENDING %}
<div class='btn-group'> <div class='btn-group'>
@ -31,8 +32,7 @@ InvenTree | Allocate Parts
</div> </div>
{% endif %} {% endif %}
</div> </div>
-->
<table class='table table-striped table-condensed' id='build-item-list' data-toolbar='#build-item-toolbar'></table>
{% endblock %} {% endblock %}
@ -46,14 +46,6 @@ InvenTree | Allocate Parts
part: {{ build.part.pk }}, part: {{ build.part.pk }},
}; };
loadBuildOutputAllocationTable(
buildInfo,
null,
{
table: '#build-item-list',
}
);
{% for item in build.incomplete_outputs %} {% for item in build.incomplete_outputs %}
// Get the build output as a javascript object // Get the build output as a javascript object
inventreeGet('{% url 'api-stock-detail' item.pk %}', {}, inventreeGet('{% url 'api-stock-detail' item.pk %}', {},

View File

@ -7,18 +7,9 @@
<div class='alert alert-block alert-info'> <div class='alert alert-block alert-info'>
<b>{% trans "Automatically Allocate Stock" %}</b><br> <b>{% trans "Automatically Allocate Stock" %}</b><br>
{% trans "Where the following conditions are met, stock will be automatically allocated to this build" %}:<br> {% trans "The following stock items will be allocated to the specified build output" %}
<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>
</div> </div>
{% if allocations %} {% if allocations %}
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tr> <tr>
<th></th> <th></th>

View File

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

View File

@ -114,19 +114,6 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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 %} {% if build.completion_date %}
<tr> <tr>
<td><span class='fas fa-calendar-alt'></span></td> <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_1), 100)
self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250) self.assertEqual(self.build.getRequiredQuantity(self.sub_part_2), 250)
self.assertTrue(self.build.can_build)
self.assertFalse(self.build.is_complete) self.assertFalse(self.build.is_complete)
# Delete some stock and see if the build can still be completed # Delete some stock and see if the build can still be completed
self.stock_2_1.delete() self.stock_2_1.delete()
self.assertFalse(self.build.can_build)
def test_build_item_clean(self): def test_build_item_clean(self):
# Ensure that dodgy BuildItem objects cannot be created # Ensure that dodgy BuildItem objects cannot be created

View File

@ -95,6 +95,9 @@ class BuildAutoAllocate(AjaxUpdateView):
role_required = 'build.change' role_required = 'build.change'
def get_initial(self): def get_initial(self):
"""
Initial values for the form.
"""
initials = super().get_initial() initials = super().get_initial()
@ -102,62 +105,73 @@ class BuildAutoAllocate(AjaxUpdateView):
output = self.get_param('output') output = self.get_param('output')
if output: if output:
initials['output_id'] = 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):
""" Get the context data for form rendering. """ """
Get the context data for form rendering.
"""
context = {} context = {}
output = self.get_form()['output_id'].value()
try:
build = Build.objects.get(id=self.kwargs['pk'])
context['build'] = build
context['allocations'] = build.getAutoAllocations(output)
except Build.DoesNotExist:
context['error'] = _('No matching build found')
return context
def post(self, request, *args, **kwargs):
""" Handle POST request. Perform auto allocations.
- If the form validation passes, perform allocations
- Otherwise, the form is passed back to the client
"""
build = self.get_object() build = self.get_object()
form = self.get_form() form = self.get_form()
confirm = request.POST.get('confirm', False) output_id = form['output'].value()
output = None
output_id = request.POST.get('output_id', None)
if output_id:
try: try:
output = StockItem.objects.get(pk=output_id) output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist): except (ValueError, StockItem.DoesNotExist):
pass output = None
valid = False if output:
context['output'] = output
context['allocations'] = build.getAutoAllocations(output)
if confirm: context['build'] = build
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 = { return context
'form_valid': valid,
def get_form(self):
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()
output = form.cleaned_data.get('output', None)
build.autoAllocate(output)
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): class BuildOutputDelete(AjaxUpdateView):
""" """
@ -292,37 +306,60 @@ class BuildComplete(AjaxUpdateView):
model = Build model = Build
form_class = forms.CompleteBuildForm form_class = forms.CompleteBuildForm
context_object_name = "build" context_object_name = "build"
ajax_form_title = _("Complete Build") ajax_form_title = _("Complete Build Output")
ajax_template_name = "build/complete.html" ajax_template_name = "build/complete.html"
role_required = 'build.change' role_required = 'build.change'
def get_form(self): def get_form(self):
""" Get the form object.
If the part is trackable, include a field for serial numbers.
"""
build = self.get_object() build = self.get_object()
form = super().get_form() form = super().get_form()
if not build.part.trackable: # Extract the build output object
form.fields.pop('serial_numbers') output = None
else: 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 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): def get_initial(self):
""" Get initial form data for the CompleteBuild form """ Get initial form data for the CompleteBuild form
- If the part being built has a default location, pre-select that location - 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() build = self.get_object()
if build.part.default_location is not None: if build.part.default_location is not None:
@ -332,94 +369,77 @@ class BuildComplete(AjaxUpdateView):
except StockLocation.DoesNotExist: except StockLocation.DoesNotExist:
pass 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 return initials
def get_context_data(self, **kwargs): 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 information is required
""" """
build = Build.objects.get(id=self.kwargs['pk']) build = self.get_object()
context = {} context = {}
# Build object # Build object
context['build'] = build context['build'] = build
# Items to be removed from stock form = self.get_form()
taking = BuildItem.objects.filter(build=build.id)
context['taking'] = taking output = form['output'].value()
if output:
try:
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
return context return context
def post(self, request, *args, **kwargs): def post_save(self, build, form, **kwargs):
""" Handle POST request. Mark the build as COMPLETE
- If the form validation passes, the Build objects completeBuild() method is called data = form.cleaned_data
- Otherwise, the form is passed back to the client
"""
build = self.get_object() location = data.get('location', None)
output = data.get('output', None)
form = self.get_form() # Complete the build output
build.completeBuildOutput(
confirm = str2bool(request.POST.get('confirm', False)) output,
self.request.user,
loc_id = request.POST.get('location', None) location=location,
)
valid = False
if confirm is False:
form.add_error('confirm', _('Confirm completion of build'))
else:
try:
location = StockLocation.objects.get(id=loc_id)
valid = True
except (ValueError, StockLocation.DoesNotExist):
form.add_error('location', _('Invalid location selected'))
serials = []
if build.part.trackable:
# A build for a trackable part may optionally specify serial numbers.
sn = request.POST.get('serial_numbers', '')
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())
def get_data(self): def get_data(self):
""" Provide feedback data back to the form """ """ Provide feedback data back to the form """
return { return {
'info': _('Build marked as COMPLETE') 'success': _('Build output completed')
} }
@ -589,19 +609,6 @@ class BuildCreate(AjaxCreateView):
_('Serial numbers already exist') + ': ' + msg _('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): class BuildUpdate(AjaxUpdateView):
""" View for editing a Build object """ """ View for editing a Build object """
@ -684,11 +691,13 @@ class BuildItemCreate(AjaxCreateView):
return ctx return ctx
def validate(self, request, form, data): def validate(self, build_item, form, **kwargs):
""" """
Extra validation steps as required Extra validation steps as required
""" """
data = form.cleaned_data
stock_item = data.get('stock_item', None) stock_item = data.get('stock_item', None)
quantity = data.get('quantity', None) quantity = data.get('quantity', None)
@ -702,7 +711,9 @@ class BuildItemCreate(AjaxCreateView):
available = stock_item.unallocated_quantity() available = stock_item.unallocated_quantity()
if quantity > available: if quantity > available:
form.add_error('stock_item', _('Stock item is over-allocated')) 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): def get_form(self):
""" Create Form for making / editing new Part object """ """ Create Form for making / editing new Part object """
@ -758,7 +769,7 @@ class BuildItemCreate(AjaxCreateView):
form.fields['install_into'].widget = HiddenInput() form.fields['install_into'].widget = HiddenInput()
if self.build and self.part: 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 form.fields['stock_item'].queryset = available_items
self.available_stock = form.fields['stock_item'].queryset.all() self.available_stock = form.fields['stock_item'].queryset.all()
@ -819,7 +830,7 @@ class BuildItemCreate(AjaxCreateView):
# Work out how much stock is required # Work out how much stock is required
if build and part: if build and part:
required_quantity = build.getUnallocatedQuantity(part, output=output) required_quantity = build.unallocatedQuantity(part, output)
else: else:
required_quantity = None required_quantity = None

View File

@ -905,6 +905,18 @@ class Part(MPTTModel):
def has_bom(self): def has_bom(self):
return self.bom_count > 0 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 @property
def bom_count(self): def bom_count(self):
""" Return the number of items contained in the BOM for this part """ """ Return the number of items contained in the BOM for this part """
@ -1188,7 +1200,7 @@ class Part(MPTTModel):
parameter.save() parameter.save()
@transaction.atomic @transaction.atomic
def deepCopy(self, other, **kwargs): def deep_copy(self, other, **kwargs):
""" Duplicates non-field data from another part. """ Duplicates non-field data from another part.
Does not alter the normal fields of this part, Does not alter the normal fields of this part,
but can be used to copy other data linked by ForeignKey refernce. 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) self.assertIn(self.R1.name, barcode)
def test_copy(self): 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): def test_match_names(self):

View File

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

View File

@ -175,7 +175,7 @@ class StockItem(MPTTModel):
if add_note: if add_note:
# This StockItem is being saved for the first time # This StockItem is being saved for the first time
self.addTransactionNote( self.addTransactionNote(
'Created stock item', _('Created stock item'),
user, user,
notes="Created new stock item for part '{p}'".format(p=str(self.part)), notes="Created new stock item for part '{p}'".format(p=str(self.part)),
system=True system=True
@ -200,12 +200,6 @@ class StockItem(MPTTModel):
super(StockItem, self).validate_unique(exclude) 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 the serial number is set, make sure it is not a duplicate
if self.serial is not None: if self.serial is not None:
# Query to look for duplicate serial numbers # Query to look for duplicate serial numbers

View File

@ -152,17 +152,7 @@ function loadBomTable(table, options) {
var sub_part = row.sub_part_detail; var sub_part = row.sub_part_detail;
if (sub_part.trackable) { html += makePartIcons(row.sub_part_detail);
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" %}');
}
// Display an extra icon if this part is an assembly // Display an extra icon if this part is an assembly
if (sub_part.assembly) { if (sub_part.assembly) {
@ -171,10 +161,6 @@ function loadBomTable(table, options) {
html += renderLink(text, `/part/${row.sub_part}/bom/`); 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; return html;
} }
} }

View File

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

View File

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