Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-02-18 22:06:32 +11:00
commit d944e7916e
16 changed files with 368 additions and 149 deletions

View File

@ -304,11 +304,11 @@
.rowinvalid { .rowinvalid {
color: #A00; color: #A00;
font-style: italic;
} }
.rowinherited { .rowinherited {
background-color: #dde; background-color: #eee;
font-style: italic;
} }
.dropdown { .dropdown {

View File

@ -379,24 +379,32 @@ class Build(MPTTModel):
if cls.objects.count() == 0: if cls.objects.count() == 0:
return None return None
build = cls.objects.last() # Extract the "most recent" build order reference
builds = cls.objects.exclude(reference=None)
if not builds.exists():
return None
build = builds.last()
ref = build.reference ref = build.reference
if not ref: if not ref:
return None return None
tries = set() tries = set(ref)
new_ref = ref
while 1: while 1:
new_ref = increment(ref) new_ref = increment(new_ref)
if new_ref in tries: if new_ref in tries:
# We are potentially stuck in a loop - simply return the original reference # We are potentially stuck in a loop - simply return the original reference
return ref return ref
# Check if the existing build reference exists
if cls.objects.filter(reference=new_ref).exists(): if cls.objects.filter(reference=new_ref).exists():
tries.add(new_ref) tries.add(new_ref)
new_ref = increment(new_ref)
else: else:
break break

View File

@ -1,9 +1,10 @@
{% extends "build/build_base.html" %} {% extends "build/build_base.html" %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block details %}
{% load status_codes %} {% load status_codes %}
{% block details %}
{% include "build/tabs.html" with tab='details' %} {% include "build/tabs.html" with tab='details' %}
<h4>{% trans "Build Details" %}</h4> <h4>{% trans "Build Details" %}</h4>

View File

@ -0,0 +1,28 @@
{% extends "build/build_base.html" %}
{% load static %}
{% load i18n %}
{% load status_codes %}
{% block details %}
{% include "build/tabs.html" with tab='parts' %}
<h4>{% trans "Build Parts" %}</h4>
<hr>
<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

@ -5,16 +5,22 @@
<a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a> <a href="{% url 'build-detail' build.id %}">{% trans "Details" %}</a>
</li> </li>
{% if build.active %} {% if build.active %}
<li {% if tab == 'parts' %} class='active'{% endif %}>
<a href='{% url "build-parts" build.id %}'>
{% trans "Required Parts" %}
<span class='badge'>{{ build.part.bom_count }}</span>
</a>
</li>
<li{% if tab == 'allocate' %} class='active'{% endif %}> <li{% if tab == 'allocate' %} class='active'{% endif %}>
<a href="{% url 'build-allocate' build.id %}"> <a href="{% url 'build-allocate' build.id %}">
{% trans "Incomplete" %} {% trans "In Progress" %}
<span class='badge'>{{ build.incomplete_outputs.count }}</span> <span class='badge'>{{ build.incomplete_outputs.count }}</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li{% if tab == 'output' %} class='active'{% endif %}> <li{% if tab == 'output' %} class='active'{% endif %}>
<a href="{% url 'build-output' build.id %}"> <a href="{% url 'build-output' build.id %}">
{% trans "Build Outputs" %} {% trans "Completed Outputs" %}
<span class='badge'>{{ build.output_count }}</span> <span class='badge'>{{ build.output_count }}</span>
</a> </a>
</li> </li>

View File

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

View File

@ -158,6 +158,14 @@ $("#po-create").click(function() {
launchModalForm("{% url 'po-create' %}", launchModalForm("{% url 'po-create' %}",
{ {
follow: true, follow: true,
secondary: [
{
field: 'supplier',
label: '{% trans "New Supplier" %}',
title: '{% trans "Create new Supplier" %}',
url: '{% url "supplier-create" %}',
}
]
} }
); );
}); });

View File

@ -465,6 +465,18 @@ class PartList(generics.ListCreateAPIView):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
# Filter by "uses" query - Limit to parts which use the provided part
uses = params.get('uses', None)
if uses:
try:
uses = Part.objects.get(pk=uses)
queryset = queryset.filter(uses.get_used_in_filter())
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'ancestor'? # Filter by 'ancestor'?
ancestor = params.get('ancestor', None) ancestor = params.get('ancestor', None)
@ -840,12 +852,6 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass pass
# Filter by sub-part?
sub_part = params.get('sub_part', None)
if sub_part is not None:
queryset = queryset.filter(sub_part=sub_part)
# Filter by "active" status of the part # Filter by "active" status of the part
part_active = params.get('part_active', None) part_active = params.get('part_active', None)

View File

@ -643,7 +643,7 @@ class Part(MPTTModel):
super().clean() super().clean()
if self.trackable: if self.trackable:
for item in self.used_in.all(): for item in self.get_used_in().all():
parent_part = item.part parent_part = item.part
if not parent_part.trackable: if not parent_part.trackable:
parent_part.trackable = True parent_part.trackable = True
@ -891,10 +891,10 @@ class Part(MPTTModel):
Return list of outstanding build orders which require this part Return list of outstanding build orders which require this part
""" """
# List of BOM that this part is required for # List parts that this part is required for
boms = BomItem.objects.filter(sub_part=self) parts = self.get_used_in().all()
part_ids = [bom.part.pk for bom in boms] part_ids = [part.pk for part in parts]
# Now, get a list of outstanding build orders which require this part # Now, get a list of outstanding build orders which require this part
builds = BuildModels.Build.objects.filter( builds = BuildModels.Build.objects.filter(
@ -909,16 +909,8 @@ class Part(MPTTModel):
Return the quantity of this part required for active build orders Return the quantity of this part required for active build orders
""" """
# List of BOM that this part is required for # List active build orders which reference this part
boms = BomItem.objects.filter(sub_part=self) builds = self.requiring_build_orders()
part_ids = [bom.part.pk for bom in boms]
# Now, get a list of outstanding build orders which require this part
builds = BuildModels.Build.objects.filter(
part__in=part_ids,
status__in=BuildStatus.ACTIVE_CODES
)
quantity = 0 quantity = 0
@ -926,19 +918,15 @@ class Part(MPTTModel):
bom_item = None bom_item = None
# List the bom lines required to make the build (including inherited ones!)
bom_items = build.part.get_bom_items().filter(sub_part=self)
# Match BOM item to build # Match BOM item to build
for bom in boms: for bom_item in bom_items:
if bom.part == build.part:
bom_item = bom
break
if bom_item is None: build_quantity = build.quantity * bom_item.quantity
logger.warning("Found null BomItem when calculating required quantity")
continue
build_quantity = build.quantity * bom_item.quantity quantity += build_quantity
quantity += build_quantity
return quantity return quantity
@ -1240,6 +1228,54 @@ class Part(MPTTModel):
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited)) return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
def get_used_in_filter(self, include_inherited=True):
"""
Return a query filter for all parts that this part is used in.
There are some considerations:
a) This part may be directly specified against a BOM for a part
b) This part may be specifed in a BOM which is then inherited by another part
Note: This function returns a Q object, not an actual queryset.
The Q object is used to filter against a list of Part objects
"""
# This is pretty expensive - we need to traverse multiple variant lists!
# TODO - In the future, could this be improved somehow?
# Keep a set of Part ID values
parts = set()
# First, grab a list of all BomItem objects which "require" this part
bom_items = BomItem.objects.filter(sub_part=self)
for bom_item in bom_items:
# Add the directly referenced part
parts.add(bom_item.part)
# Traverse down the variant tree?
if include_inherited and bom_item.inherited:
part_variants = bom_item.part.get_descendants(include_self=False)
for variant in part_variants:
parts.add(variant)
# Turn into a list of valid IDs (for matching against a Part query)
part_ids = [part.pk for part in parts]
return Q(id__in=part_ids)
def get_used_in(self, include_inherited=True):
"""
Return a queryset containing all parts this part is used in.
Includes consideration of inherited BOMs
"""
return Part.objects.filter(self.get_used_in_filter(include_inherited=include_inherited))
@property @property
def has_bom(self): def has_bom(self):
return self.get_bom_items().count() > 0 return self.get_bom_items().count() > 0
@ -1265,7 +1301,7 @@ class Part(MPTTModel):
@property @property
def used_in_count(self): def used_in_count(self):
""" Return the number of part BOMs that this part appears in """ """ Return the number of part BOMs that this part appears in """
return self.used_in.count() return self.get_used_in().count()
def get_bom_hash(self): def get_bom_hash(self):
""" Return a checksum hash for the BOM for this part. """ Return a checksum hash for the BOM for this part.
@ -1364,7 +1400,7 @@ class Part(MPTTModel):
parts = parts.exclude(id=self.id) parts = parts.exclude(id=self.id)
# Exclude any parts that this part is used *in* (to prevent recursive BOMs) # Exclude any parts that this part is used *in* (to prevent recursive BOMs)
used_in = self.used_in.all() used_in = self.get_used_in().all()
parts = parts.exclude(id__in=[item.part.id for item in used_in]) parts = parts.exclude(id__in=[item.part.id for item in used_in])
@ -1524,7 +1560,7 @@ class Part(MPTTModel):
# Copy existing BOM items from another part # Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!! # Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.bom_items.all(): for bom_item in other.get_bom_items(include_inherited=False).all():
# If this part already has a BomItem pointing to the same sub-part, # If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first! # delete that BomItem from this part first!
@ -2094,6 +2130,8 @@ class BomItem(models.Model):
- Quantity - Quantity
- Reference field - Reference field
- Note field - Note field
- Optional field
- Inherited field
""" """
@ -2106,6 +2144,8 @@ class BomItem(models.Model):
hash.update(str(self.quantity).encode()) hash.update(str(self.quantity).encode())
hash.update(str(self.note).encode()) hash.update(str(self.note).encode())
hash.update(str(self.reference).encode()) hash.update(str(self.reference).encode())
hash.update(str(self.optional).encode())
hash.update(str(self.inherited).encode())
return str(hash.digest()) return str(hash.digest())

View File

@ -77,19 +77,6 @@
{% endblock %} {% endblock %}
{% block js_load %}
{{ block.super }}
<!-- jquery-treegrid -->
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.js" %}'></script>
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.bootstrap3.js" %}'></script>
<!-- boostrap-table-treegrid -->
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}

View File

@ -133,11 +133,18 @@
<td>{% decimal on_order %}</td> <td>{% decimal on_order %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if required > 0 %} {% if required_build_order_quantity > 0 %}
<tr> <tr>
<td><span class='fas fa-clipboard-list'></span></td> <td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Orders" %}</td> <td>{% trans "Required for Build Orders" %}</td>
<td>{% decimal required %} <td>{% decimal required_build_order_quantity %}
</tr>
{% endif %}
{% if required_sales_order_quantity > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Sales Orders" %}</td>
<td>{% decimal required_sales_order_quantity %}
</tr> </tr>
{% endif %} {% endif %}
{% if allocated > 0 %} {% if allocated > 0 %}

View File

@ -22,10 +22,14 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
loadUsedInTable('#used-table', { loadSimplePartTable('#used-table',
part_detail: true, '{% url "api-part-list" %}',
part_id: {{ part.pk }} {
}); params: {
uses: {{ part.pk }},
}
}
);
{% endblock %} {% endblock %}

View File

@ -107,6 +107,13 @@ InvenTree
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script> <script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script>
<!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> --> <!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> -->
<!-- jquery-treegrid -->
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.js" %}'></script>
<script type='text/javascript' src='{% static "treegrid/js/jquery.treegrid.bootstrap3.js" %}'></script>
<!-- boostrap-table-treegrid -->
<script type='text/javascript' src='{% static "bootstrap-table/extensions/treegrid/bootstrap-table-treegrid.js" %}'></script>
<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script> <script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script> <script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script> <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>

View File

@ -539,85 +539,3 @@ function loadBomTable(table, options) {
}); });
} }
} }
function loadUsedInTable(table, options) {
/* Load a table which displays all the parts that the given part is used in.
*/
var params = {
sub_part: options.part_id,
ordering: 'name',
}
if (options.part_detail) {
params.part_detail = true;
}
if (options.sub_part_detail) {
params.sub_part_detail = true;
}
var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters("usedin");
}
for (var key in params) {
filters[key] = params[key];
}
setupFilterList("usedin", $(table));
// Columns to display in the table
var cols = [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part_detail.full_name',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var link = `/part/${row.part}/bom/`;
var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(row.part_detail.full_name, link);
if (!row.part_detail.active) {
html += "<span class='label label-warning' style='float: right;'>{% trans 'INACTIVE' %}</span>";
}
return html;
}
},
{
field: 'part_detail.description',
title: '{% trans "Description" %}',
sortable: true,
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Uses" %}',
formatter: function(value, row, index, field) {
return parseFloat(value);
},
}
];
// Load the table
$(table).inventreeTable({
url: "{% url 'api-bom-list' %}",
formatNoMatches: function() {
return '{% trans "No matching parts found" %}';
},
columns: cols,
showColumns: true,
sortable: true,
serach: true,
queryParams: filters,
original: params,
});
}

View File

@ -834,3 +834,199 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
}); });
} }
function loadBuildPartsTable(table, options={}) {
/**
* Display a "required parts" table for build view.
*
* This is a simplified BOM view:
* - Does not display sub-bom items
* - Does not allow editing of BOM items
*
* Options:
*
* part: Part ID
* build: Build ID
* build_quantity: Total build quantity
* build_remaining: Number of items remaining
*/
// Query params
var params = {
sub_part_detail: true,
part: options.part,
};
var filters = {};
if (!options.disableFilters) {
filters = loadTableFilters('bom');
}
setupFilterList('bom', $(table));
for (var key in params) {
filters[key] = params[key];
}
function setupTableCallbacks() {
// Register button callbacks once the table data are loaded
// Callback for 'buy' button
$(table).find('.button-buy').click(function() {
var pk = $(this).attr('pk');
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
launchModalForm('{% url "order-parts" %}', {
data: {
parts: [
pk,
]
}
});
});
// Callback for 'build' button
$(table).find('.button-build').click(function() {
var pk = $(this).attr('pk');
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
// Launch form to create a new build order
launchModalForm('{% url "build-create" %}', {
follow: true,
data: {
part: pk,
parent: options.build,
}
});
});
}
var columns = [
{
field: 'sub_part',
title: '{% trans "Part" %}',
switchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part}/`;
var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url);
var sub_part = row.sub_part_detail;
html += makePartIcons(row.sub_part_detail);
// Display an extra icon if this part is an assembly
if (sub_part.assembly) {
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
html += renderLink(text, `/part/${row.sub_part}/bom/`);
}
return html;
}
},
{
field: 'sub_part_detail.description',
title: '{% trans "Description" %}',
},
{
field: 'reference',
title: '{% trans "Reference" %}',
searchable: true,
sortable: true,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true
},
{
sortable: true,
switchable: false,
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
formatter: function(value, row, index, field) {
return makeProgressBar(
value,
row.quantity * options.build_remaining,
{
id: `part-progress-${row.part}`
}
);
},
sorter: function(valA, valB, rowA, rowB) {
if (rowA.received == 0 && rowB.received == 0) {
return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
var progressA = parseFloat(rowA.sub_part_detail.stock) / (rowA.quantity * options.build_remaining);
var progressB = parseFloat(rowB.sub_part_detail.stock) / (rowB.quantity * options.build_remaining);
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'actions',
title: '{% trans "Actions" %}',
switchable: false,
formatter: function(value, row, index, field) {
// Generate action buttons against the part
var html = `<div class='btn-group float-right' role='group'>`;
if (row.sub_part_detail.assembly) {
html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}');
}
if (row.sub_part_detail.purchaseable) {
html += makeIconButton('fa-shopping-cart icon-blue', 'button-buy', row.sub_part, '{% trans "Order stock" %}');
}
html += `</div>`;
return html;
}
}
];
table.inventreeTable({
url: '{% url "api-bom-list" %}',
showColumns: true,
name: 'build-parts',
sortable: true,
search: true,
onPostBody: setupTableCallbacks,
rowStyle: function(row, index) {
var classes = [];
// Shade rows differently if they are for different parent parts
if (row.part != options.part) {
classes.push('rowinherited');
}
if (row.validated) {
classes.push('rowvalid');
} else {
classes.push('rowinvalid');
}
return {
classes: classes.join(' '),
};
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
},
clickToSelect: true,
queryParams: filters,
original: params,
columns: columns,
});
}

View File

@ -34,10 +34,12 @@
</button> </button>
<h3 id='modal-title'><i>Form Title Here</i></h3> <h3 id='modal-title'><i>Form Title Here</i></h3>
</div> </div>
<div class='alert alert-block alert-danger' id='form-validation-warning' style="display: none;"> <div class='modal-form-content-wrapper'>
{% trans "Form errors exist" %} <div class='alert alert-block alert-danger' id='form-validation-warning' style="display: none;">
</div> {% trans "Form errors exist" %}
<div class='modal-form-content'> </div>
<div class='modal-form-content'>
</div>
</div> </div>
<div class='modal-footer'> <div class='modal-footer'>
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button> <button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>