diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 50a24aa095..ac3d402c3b 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -304,11 +304,11 @@ .rowinvalid { color: #A00; - font-style: italic; } .rowinherited { - background-color: #dde; + background-color: #eee; + font-style: italic; } .dropdown { diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index ae7ea14ece..2872fecb55 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -379,24 +379,32 @@ class Build(MPTTModel): if cls.objects.count() == 0: 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 if not ref: return None - tries = set() + tries = set(ref) + + new_ref = ref while 1: - new_ref = increment(ref) + new_ref = increment(new_ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref + # Check if the existing build reference exists if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) - new_ref = increment(new_ref) else: break diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 40dc772c4f..5e56990c3e 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -1,9 +1,10 @@ {% extends "build/build_base.html" %} {% load static %} {% load i18n %} -{% block details %} {% load status_codes %} +{% block details %} + {% include "build/tabs.html" with tab='details' %}

{% trans "Build Details" %}

diff --git a/InvenTree/build/templates/build/parts.html b/InvenTree/build/templates/build/parts.html new file mode 100644 index 0000000000..3b80cf551b --- /dev/null +++ b/InvenTree/build/templates/build/parts.html @@ -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' %} + +

{% trans "Build Parts" %}

+
+ +
+ +{% 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 %} \ No newline at end of file diff --git a/InvenTree/build/templates/build/tabs.html b/InvenTree/build/templates/build/tabs.html index 8bdb2a3013..c6d2893620 100644 --- a/InvenTree/build/templates/build/tabs.html +++ b/InvenTree/build/templates/build/tabs.html @@ -5,16 +5,22 @@ {% trans "Details" %} {% if build.active %} +
  • + + {% trans "Required Parts" %} + {{ build.part.bom_count }} + +
  • - {% trans "Incomplete" %} + {% trans "In Progress" %} {{ build.incomplete_outputs.count }} {% endif %} - {% trans "Build Outputs" %} + {% trans "Completed Outputs" %} {{ build.output_count }} diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 08142e6939..6f681f5488 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -20,6 +20,7 @@ build_detail_urls = [ 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'^output/', views.BuildDetail.as_view(template_name='build/build_output.html'), name='build-output'), diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html index 52314df833..262f083074 100644 --- a/InvenTree/order/templates/order/purchase_orders.html +++ b/InvenTree/order/templates/order/purchase_orders.html @@ -158,6 +158,14 @@ $("#po-create").click(function() { launchModalForm("{% url 'po-create' %}", { follow: true, + secondary: [ + { + field: 'supplier', + label: '{% trans "New Supplier" %}', + title: '{% trans "Create new Supplier" %}', + url: '{% url "supplier-create" %}', + } + ] } ); }); diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 1fd5092f14..04a37e5fff 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -465,6 +465,18 @@ class PartList(generics.ListCreateAPIView): 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'? ancestor = params.get('ancestor', None) @@ -839,12 +851,6 @@ class BomList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): 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 part_active = params.get('part_active', None) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 911a2cdac4..c1dbb454b4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -643,7 +643,7 @@ class Part(MPTTModel): super().clean() if self.trackable: - for item in self.used_in.all(): + for item in self.get_used_in().all(): parent_part = item.part if not parent_part.trackable: parent_part.trackable = True @@ -891,10 +891,10 @@ class Part(MPTTModel): Return list of outstanding build orders which require this part """ - # List of BOM that this part is required for - boms = BomItem.objects.filter(sub_part=self) + # List parts that this part is required for + 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 builds = BuildModels.Build.objects.filter( @@ -909,36 +909,24 @@ class Part(MPTTModel): Return the quantity of this part required for active build orders """ - # List of BOM that this part is required for - boms = BomItem.objects.filter(sub_part=self) - - 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 - ) + # List active build orders which reference this part + builds = self.requiring_build_orders() quantity = 0 for build in builds: - + 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 - for bom in boms: - if bom.part == build.part: - bom_item = bom - break + for bom_item in bom_items: - if bom_item is None: - logger.warning("Found null BomItem when calculating required quantity") - continue + build_quantity = build.quantity * bom_item.quantity - build_quantity = build.quantity * bom_item.quantity - - quantity += build_quantity + quantity += build_quantity return quantity @@ -1240,6 +1228,54 @@ class Part(MPTTModel): 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 def has_bom(self): return self.get_bom_items().count() > 0 @@ -1265,7 +1301,7 @@ class Part(MPTTModel): @property def used_in_count(self): """ 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): """ Return a checksum hash for the BOM for this part. @@ -1364,7 +1400,7 @@ class Part(MPTTModel): parts = parts.exclude(id=self.id) # 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]) @@ -1524,7 +1560,7 @@ class Part(MPTTModel): # Copy existing BOM items from another part # 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, # delete that BomItem from this part first! @@ -2094,6 +2130,8 @@ class BomItem(models.Model): - Quantity - Reference 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.note).encode()) hash.update(str(self.reference).encode()) + hash.update(str(self.optional).encode()) + hash.update(str(self.inherited).encode()) return str(hash.digest()) diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index ca0446378c..322d637089 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -77,19 +77,6 @@ {% endblock %} -{% block js_load %} -{{ block.super }} - - - - - - - - -{% endblock %} - - {% block js_ready %} {{ block.super }} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 1221c271b6..6b0163089d 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -133,11 +133,18 @@ {% decimal on_order %} {% endif %} - {% if required > 0 %} + {% if required_build_order_quantity > 0 %} - {% trans "Required for Orders" %} - {% decimal required %} + {% trans "Required for Build Orders" %} + {% decimal required_build_order_quantity %} + + {% endif %} + {% if required_sales_order_quantity > 0 %} + + + {% trans "Required for Sales Orders" %} + {% decimal required_sales_order_quantity %} {% endif %} {% if allocated > 0 %} diff --git a/InvenTree/part/templates/part/used_in.html b/InvenTree/part/templates/part/used_in.html index 25e858b5f0..686578a93a 100644 --- a/InvenTree/part/templates/part/used_in.html +++ b/InvenTree/part/templates/part/used_in.html @@ -22,10 +22,14 @@ {% block js_ready %} {{ block.super }} - loadUsedInTable('#used-table', { - part_detail: true, - part_id: {{ part.pk }} - }); + loadSimplePartTable('#used-table', + '{% url "api-part-list" %}', + { + params: { + uses: {{ part.pk }}, + } + } + ); {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index c1640892de..bb78e4a45d 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -107,6 +107,13 @@ InvenTree + + + + + + + diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index 76c65d44dd..beb4110df6 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -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 += "{% trans 'INACTIVE' %}"; - } - - 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, - }); -} diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 965207de66..cb7e27486b 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -833,4 +833,200 @@ 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 = ``; + + 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 = `
    `; + + 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 += `
    `; + + 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, + }); } \ No newline at end of file diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index a162a3f167..11166751f8 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -34,10 +34,12 @@ - -