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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

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

View File

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