diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 33f3f4ab36..a720f7cbe0 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -96,6 +96,7 @@ class BuildList(generics.ListCreateAPIView): 'target_date', 'completion_date', 'quantity', + 'completed', 'issued_by', 'responsible', ] @@ -442,6 +443,18 @@ class BuildItemList(generics.ListCreateAPIView): if part_pk: queryset = queryset.filter(stock_item__part=part_pk) + # Filter by "tracked" status + # Tracked means that the item is "installed" into a build output (stock item) + tracked = params.get('tracked', None) + + if tracked is not None: + tracked = str2bool(tracked) + + if tracked: + queryset = queryset.exclude(install_into=None) + else: + queryset = queryset.filter(install_into=None) + # Filter by output target output = params.get('output', None) diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index e5189e6073..86bb256539 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -1260,7 +1260,7 @@ class BuildItem(models.Model): }) @transaction.atomic - def complete_allocation(self, user): + def complete_allocation(self, user, notes=''): """ Complete the allocation of this BuildItem into the output stock item. @@ -1286,8 +1286,13 @@ class BuildItem(models.Model): self.save() # Install the stock item into the output - item.belongs_to = self.install_into - item.save() + self.install_into.installStockItem( + item, + self.quantity, + user, + notes + ) + else: # Simply remove the items from stock item.take_stock( diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 4b55182563..d037ad546e 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -161,7 +161,12 @@ class BuildOutputSerializer(serializers.Serializer): # The build output must have all tracked parts allocated if not build.is_fully_allocated(output): - raise ValidationError(_("This build output is not fully allocated")) + + # Check if the user has specified that incomplete allocations are ok + accept_incomplete = InvenTree.helpers.str2bool(self.context['request'].data.get('accept_incomplete_allocation', False)) + + if not accept_incomplete: + raise ValidationError(_("This build output is not fully allocated")) return output @@ -355,6 +360,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer): 'outputs', 'location', 'status', + 'accept_incomplete_allocation', 'notes', ] @@ -377,6 +383,13 @@ class BuildOutputCompleteSerializer(serializers.Serializer): label=_("Status"), ) + accept_incomplete_allocation = serializers.BooleanField( + default=False, + required=False, + label=_('Accept Incomplete Allocation'), + help_text=_('Complete ouputs if stock has not been fully allocated'), + ) + notes = serializers.CharField( label=_("Notes"), required=False, @@ -617,6 +630,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): super().validate(data) + build = self.context['build'] bom_item = data['bom_item'] stock_item = data['stock_item'] quantity = data['quantity'] @@ -641,16 +655,20 @@ class BuildAllocationItemSerializer(serializers.Serializer): # Output *must* be set for trackable parts if output is None and bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output must be specified for allocation of tracked parts') + 'output': _('Build output must be specified for allocation of tracked parts'), }) # Output *cannot* be set for un-tracked parts if output is not None and not bom_item.sub_part.trackable: raise ValidationError({ - 'output': _('Build output cannot be specified for allocation of untracked parts') + 'output': _('Build output cannot be specified for allocation of untracked parts'), }) + # Check if this allocation would be unique + if BuildItem.objects.filter(build=build, stock_item=stock_item, install_into=output).exists(): + raise ValidationError(_('This stock item has already been allocated to this build output')) + return data diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 92e1177e0f..42bc51bb2f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -270,6 +270,16 @@ + {% if build.has_tracked_bom_items %} + + + + {% endif %} + {% include "filter_list.html" with id='incompletebuilditems' %} {% endif %} @@ -401,110 +411,53 @@ function reloadTable() { $('#allocation-table-untracked').bootstrapTable('refresh'); } -// Get the list of BOM items required for this build -inventreeGet( - '{% url "api-bom-list" %}', - { +onPanelLoad('outputs', function() { + {% if build.active %} + + var build_info = { + pk: {{ build.pk }}, part: {{ build.part.pk }}, - sub_part_detail: true, - }, - { - success: function(response) { + quantity: {{ build.quantity }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + tracked_parts: true, + }; - var build_info = { - pk: {{ build.pk }}, - part: {{ build.part.pk }}, - quantity: {{ build.quantity }}, - bom_items: response, - {% if build.take_from %} - source_location: {{ build.take_from.pk }}, - {% endif %} - {% if build.has_tracked_bom_items %} - tracked_parts: true, - {% else %} - tracked_parts: false, - {% endif %} - }; + loadBuildOutputTable(build_info); - {% if build.active %} - loadBuildOutputTable(build_info); - linkButtonsToSelection( - '#build-output-table', - [ - '#output-options', - '#multi-output-complete', - '#multi-output-delete', - ] - ); + {% endif %} +}); - $('#multi-output-complete').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); +{% if build.active and build.has_untracked_bom_items %} - completeBuildOutputs( - build_info.pk, - outputs, - { - success: function() { - // Reload the "in progress" table - $('#build-output-table').bootstrapTable('refresh'); +function loadUntrackedStockTable() { - // Reload the "completed" table - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ); - }); + var build_info = { + pk: {{ build.pk }}, + part: {{ build.part.pk }}, + quantity: {{ build.quantity }}, + {% if build.take_from %} + source_location: {{ build.take_from.pk }}, + {% endif %} + tracked_parts: false, + }; + + $('#allocation-table-untracked').bootstrapTable('destroy'); - $('#multi-output-delete').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); - - deleteBuildOutputs( - build_info.pk, - outputs, - { - success: function() { - // Reload the "in progress" table - $('#build-output-table').bootstrapTable('refresh'); - - // Reload the "completed" table - $('#build-stock-table').bootstrapTable('refresh'); - } - } - ) - }); - - $('#incomplete-output-print-label').click(function() { - var outputs = $('#build-output-table').bootstrapTable('getSelections'); - - if (outputs.length == 0) { - outputs = $('#build-output-table').bootstrapTable('getData'); - } - - var stock_id_values = []; - - outputs.forEach(function(output) { - stock_id_values.push(output.pk); - }); - - printStockItemLabels(stock_id_values); - - }); - - {% endif %} - - {% if build.active and build.has_untracked_bom_items %} - // Load allocation table for un-tracked parts - loadBuildOutputAllocationTable( - build_info, - null, - { - search: true, - } - ); - {% endif %} + // Load allocation table for un-tracked parts + loadBuildOutputAllocationTable( + build_info, + null, + { + search: true, } - } -); + ); +} + +loadUntrackedStockTable(); + +{% endif %} $('#btn-create-output').click(function() { @@ -527,6 +480,7 @@ $("#btn-auto-allocate").on('click', function() { {% if build.take_from %} location: {{ build.take_from.pk }}, {% endif %} + onSuccess: loadUntrackedStockTable, } ); }); @@ -558,9 +512,7 @@ $("#btn-allocate").on('click', function() { {% if build.take_from %} source_location: {{ build.take_from.pk }}, {% endif %} - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + success: loadUntrackedStockTable, } ); } @@ -569,6 +521,7 @@ $("#btn-allocate").on('click', function() { $('#btn-unallocate').on('click', function() { unallocateStock({{ build.id }}, { table: '#allocation-table-untracked', + onSuccess: loadUntrackedStockTable, }); }); @@ -588,9 +541,7 @@ $('#allocate-selected-items').click(function() { {% if build.take_from %} source_location: {{ build.take_from.pk }}, {% endif %} - success: function(data) { - $('#allocation-table-untracked').bootstrapTable('refresh'); - } + success: loadUntrackedStockTable, } ); }); diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 1a80c87322..3752f7daf4 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -383,7 +383,7 @@ class PartTestTemplateList(generics.ListCreateAPIView): required = params.get('required', None) if required is not None: - queryset = queryset.filter(required=required) + queryset = queryset.filter(required=str2bool(required)) return queryset diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index d4fc5c93d1..c88c29e64e 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -23,6 +23,8 @@ from rest_framework.serializers import ValidationError from rest_framework.response import Response from rest_framework import generics, filters +from build.models import Build + import common.settings import common.models @@ -1159,6 +1161,19 @@ class StockItemTestResultList(generics.ListCreateAPIView): queryset = super().filter_queryset(queryset) + # Filter by 'build' + build = params.get('build', None) + + if build is not None: + + try: + build = Build.objects.get(pk=build) + + queryset = queryset.filter(stock_item__build=build) + + except (ValueError, Build.DoesNotExist): + pass + # Filter by stock item item = params.get('stock_item', None) diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 2d7796edcd..e4674d5989 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -743,11 +743,29 @@ function loadBomTable(table, options={}) { field: 'sub_part', title: '{% trans "Part" %}', sortable: true, + switchable: false, formatter: function(value, row) { var url = `/part/${row.sub_part}/`; - var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url); + var html = ''; var sub_part = row.sub_part_detail; + + // Display an extra icon if this part is an assembly + if (sub_part.assembly) { + + if (row.sub_assembly_received) { + // Data received, ignore + } else if (row.sub_assembly_requested) { + html += ``; + } else { + html += ` + + + `; + } + } + + html += imageHoverIcon(sub_part.thumbnail) + renderLink(row.sub_part_detail.full_name, url); html += makePartIcons(sub_part); @@ -759,13 +777,6 @@ function loadBomTable(table, options={}) { html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}'); } - // 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; } } @@ -1027,14 +1038,6 @@ function loadBomTable(table, options={}) { // This function may be called recursively for multi-level BOMs function requestSubItems(bom_pk, part_pk, depth=0) { - // Prevent multi-level recursion - const MAX_BOM_DEPTH = 25; - - if (depth >= MAX_BOM_DEPTH) { - console.log(`Maximum BOM depth (${MAX_BOM_DEPTH}) reached!`); - return; - } - inventreeGet( options.bom_url, { @@ -1049,17 +1052,13 @@ function loadBomTable(table, options={}) { for (var idx = 0; idx < response.length; idx++) { response[idx].parentId = bom_pk; } + + var row = $(table).bootstrapTable('getRowByUniqueId', bom_pk); + row.sub_assembly_received = true; + + $(table).bootstrapTable('updateByUniqueId', bom_pk, row, true); table.bootstrapTable('append', response); - - // Next, re-iterate and check if the new items also have sub items - response.forEach(function(bom_item) { - if (bom_item.sub_part_detail.assembly) { - requestSubItems(bom_item.pk, bom_item.sub_part, depth + 1); - } - }); - - table.treegrid('collapseAll'); }, error: function(xhr) { console.log('Error requesting BOM for part=' + part_pk); @@ -1103,7 +1102,6 @@ function loadBomTable(table, options={}) { formatNoMatches: function() { return '{% trans "No BOM items found" %}'; }, - clickToSelect: true, queryParams: filters, original: params, columns: cols, @@ -1117,32 +1115,26 @@ function loadBomTable(table, options={}) { }); table.treegrid('collapseAll'); + + // Callback for 'load sub assembly' button + $(table).find('.load-sub-assembly').click(function(event) { + + event.preventDefault(); + + var pk = $(this).attr('pk'); + var row = $(table).bootstrapTable('getRowByUniqueId', pk); + + // Request BOM data for this subassembly + requestSubItems(row.pk, row.sub_part); + + row.sub_assembly_requested = true; + $(table).bootstrapTable('updateByUniqueId', pk, row, true); + }); }, onLoadSuccess: function() { - if (options.editable) { table.bootstrapTable('uncheckAll'); } - - var data = table.bootstrapTable('getData'); - - for (var idx = 0; idx < data.length; idx++) { - var row = data[idx]; - - // If a row already has a parent ID set, it's already been updated! - if (row.parentId) { - continue; - } - - // Set the parent ID of the top-level rows - row.parentId = parent_id; - - table.bootstrapTable('updateRow', idx, row, true); - - if (row.sub_part_detail.assembly) { - requestSubItems(row.pk, row.sub_part); - } - } }, }); diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 65fc3a4d6c..c9ebbe0e22 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -264,7 +264,7 @@ function makeBuildOutputButtons(output_id, build_info, options={}) { var html = `
`; // Tracked parts? Must be individually allocated - if (build_info.tracked_parts) { + if (options.has_bom_items) { // Add a button to allocate stock against this build output html += makeIconButton( @@ -342,7 +342,9 @@ function unallocateStock(build_id, options={}) { }, title: '{% trans "Unallocate Stock Items" %}', onSuccess: function(response, opts) { - if (options.table) { + if (options.onSuccess) { + options.onSuccess(response, opts); + } else if (options.table) { // Reload the parent table $(options.table).bootstrapTable('refresh'); } @@ -427,6 +429,8 @@ function completeBuildOutputs(build_id, outputs, options={}) { fields: { status: {}, location: {}, + notes: {}, + accept_incomplete_allocation: {}, }, confirm: true, title: '{% trans "Complete Build Outputs" %}', @@ -445,6 +449,8 @@ function completeBuildOutputs(build_id, outputs, options={}) { outputs: [], status: getFormFieldValue('status', {}, opts), location: getFormFieldValue('location', {}, opts), + notes: getFormFieldValue('notes', {}, opts), + accept_incomplete_allocation: getFormFieldValue('accept_incomplete_allocation', {type: 'boolean'}, opts), }; var output_pk_values = []; @@ -720,6 +726,35 @@ function loadBuildOrderAllocationTable(table, options={}) { } +/* Internal helper functions for performing calculations on BOM data */ + +// Iterate through a list of allocations, returning *only* those which match a particular BOM row +function getAllocationsForBomRow(bom_row, allocations) { + var part_id = bom_row.sub_part; + + var matching_allocations = []; + + allocations.forEach(function(allocation) { + if (allocation.bom_part == part_id) { + matching_allocations.push(allocation); + } + }); + + return matching_allocations; +} + +// Sum the allocation quantity for a given BOM row +function sumAllocationsForBomRow(bom_row, allocations) { + var quantity = 0; + + getAllocationsForBomRow(bom_row, allocations).forEach(function(allocation) { + quantity += allocation.quantity; + }); + + return parseFloat(quantity).toFixed(15); +} + + /* * Display a "build output" table for a particular build. * @@ -737,18 +772,6 @@ function loadBuildOutputTable(build_info, options={}) { params.is_building = true; params.build = build_info.pk; - // Construct a list of "tracked" BOM items - var tracked_bom_items = []; - - var has_tracked_items = false; - - build_info.bom_items.forEach(function(bom_item) { - if (bom_item.sub_part_detail.trackable) { - tracked_bom_items.push(bom_item); - has_tracked_items = true; - }; - }); - var filters = {}; for (var key in params) { @@ -786,7 +809,7 @@ function loadBuildOutputTable(build_info, options={}) { } ); } else { - console.log(`WARNING: Could not locate sub-table for output ${pk}`); + console.warn(`Could not locate sub-table for output ${pk}`); } }); @@ -841,6 +864,26 @@ function loadBuildOutputTable(build_info, options={}) { }); } + // List of "tracked bom items" required for this build order + var bom_items = null; + + // Request list of BOM data for this build order + inventreeGet( + '{% url "api-bom-list" %}', + { + part: build_info.part, + sub_part_detail: true, + sub_part_trackable: true, + }, + { + async: false, + success: function(response) { + // Save the BOM items + bom_items = response; + } + } + ); + /* * Construct a "sub table" showing the required BOM items */ @@ -855,6 +898,9 @@ function loadBuildOutputTable(build_info, options={}) { element.html(html); + // Pass through the cached BOM items + build_info.bom_items = bom_items; + loadBuildOutputAllocationTable( build_info, row, @@ -865,19 +911,180 @@ function loadBuildOutputTable(build_info, options={}) { ); } + function updateAllocationData(rows) { + // Update stock allocation information for the build outputs + + // Request updated stock allocation data for this build order + inventreeGet( + '{% url "api-build-item-list" %}', + { + build: build_info.pk, + part_detail: true, + location_detail: true, + sub_part_trackable: true, + tracked: true, + }, + { + success: function(response) { + + // Group allocation information by the "install_into" field + var allocations = {}; + + response.forEach(function(allocation) { + var target = allocation.install_into; + + if (target != null) { + if (!(target in allocations)) { + allocations[target] = []; + } + + allocations[target].push(allocation); + } + }); + + // Now that the allocations have been grouped by stock item, + // we can update each row in the table, + // using the pk value of each row (stock item) + rows.forEach(function(row) { + row.allocations = allocations[row.pk] || []; + $(table).bootstrapTable('updateByUniqueId', row.pk, row, true); + + var n_completed_lines = 0; + + // Check how many BOM lines have been completely allocated for this build output + bom_items.forEach(function(bom_item) { + + var required_quantity = bom_item.quantity * row.quantity; + + if (sumAllocationsForBomRow(bom_item, row.allocations) >= required_quantity) { + n_completed_lines += 1; + } + + var output_progress_bar = $(`#output-progress-${row.pk}`); + + if (output_progress_bar.exists()) { + output_progress_bar.html( + makeProgressBar( + n_completed_lines, + bom_items.length, + { + max_width: '150px', + } + ) + ); + } + }); + }); + } + } + ); + } + + var part_tests = null; + + function updateTestResultData(rows) { + // Update test result information for the build outputs + + // Request test template data if it has not already been retrieved + if (part_tests == null) { + inventreeGet( + '{% url "api-part-test-template-list" %}', + { + part: build_info.part, + required: true, + }, + { + success: function(response) { + // Save the list of part tests + part_tests = response; + + // Callback to this function again + updateTestResultData(rows); + } + } + ); + + return; + } + + // Retrieve stock results for the entire build + inventreeGet( + '{% url "api-stock-test-result-list" %}', + { + build: build_info.pk, + }, + { + success: function(results) { + + // Iterate through each row and find matching test results + rows.forEach(function(row) { + var test_results = {}; + + results.forEach(function(result) { + if (result.stock_item == row.pk) { + // This test result matches the particular stock item + + if (!(result.key in test_results)) { + test_results[result.key] = result.result; + } + } + }); + + row.passed_tests = test_results; + + $(table).bootstrapTable('updateByUniqueId', row.pk, row, true); + }); + } + } + ); + } + + // Return the number of 'passed' tests in a given row + function countPassedTests(row) { + if (part_tests == null) { + return 0; + } + + var results = row.passed_tests || {}; + var n = 0; + + part_tests.forEach(function(test) { + if (results[test.key] || false) { + n += 1; + } + }); + + return n; + } + + // Return the number of 'fully allocated' lines for a given row + function countAllocatedLines(row) { + var n_completed_lines = 0; + + bom_items.forEach(function(bom_row) { + var required_quantity = bom_row.quantity * row.quantity; + + if (sumAllocationsForBomRow(bom_row, row.allocations || []) >= required_quantity) { + n_completed_lines += 1; + } + }); + + return n_completed_lines; + } + $(table).inventreeTable({ url: '{% url "api-stock-list" %}', queryParams: filters, original: params, - showColumns: false, + showColumns: true, uniqueId: 'pk', name: 'build-outputs', sortable: true, search: false, - sidePagination: 'server', - detailView: has_tracked_items, + sidePagination: 'client', + detailView: bom_items.length > 0, detailFilter: function(index, row) { - return true; + return bom_items.length > 0; }, detailFormatter: function(index, row, element) { constructBuildOutputSubTable(index, row, element); @@ -885,11 +1092,14 @@ function loadBuildOutputTable(build_info, options={}) { formatNoMatches: function() { return '{% trans "No active build outputs found" %}'; }, - onPostBody: function() { + onPostBody: function(rows) { // Add callbacks for the buttons setupBuildOutputButtonCallbacks(); + }, + onLoadSuccess: function(rows) { - $(table).bootstrapTable('expandAllRows'); + updateAllocationData(rows); + updateTestResultData(rows); }, columns: [ { @@ -901,6 +1111,7 @@ function loadBuildOutputTable(build_info, options={}) { { field: 'part', title: '{% trans "Part" %}', + switchable: true, formatter: function(value, row) { var thumb = row.part_detail.thumbnail; @@ -909,7 +1120,9 @@ function loadBuildOutputTable(build_info, options={}) { }, { field: 'quantity', - title: '{% trans "Quantity" %}', + title: '{% trans "Build Output" %}', + switchable: false, + sortable: true, formatter: function(value, row) { var url = `/stock/item/${row.pk}/`; @@ -922,15 +1135,84 @@ function loadBuildOutputTable(build_info, options={}) { text = `{% trans "Quantity" %}: ${row.quantity}`; } + if (row.batch) { + text += ` ({% trans "Batch" %}: ${row.batch})`; + } + return renderLink(text, url); + }, + sorter: function(a, b, row_a, row_b) { + // Sort first by quantity, and then by serial number + if ((row_a.quantity > 1) || (row_b.quantity > 1)) { + return row_a.quantity > row_b.quantity ? 1 : -1; + } + + if ((row_a.serial != null) && (row_b.serial != null)) { + var sn_a = Number.parseInt(row_a.serial) || 0; + var sn_b = Number.parseInt(row_b.serial) || 0; + + return sn_a > sn_b ? 1 : -1; + } + + return 0; } }, { field: 'allocated', - title: '{% trans "Allocated Parts" %}', - visible: has_tracked_items, + title: '{% trans "Allocated Stock" %}', + visible: bom_items.length > 0, + switchable: false, + sortable: true, formatter: function(value, row) { - return `
`; + + if (bom_items.length == 0) { + return `
{% trans "No tracked BOM items for this build" %}
`; + } + + var progressBar = makeProgressBar( + countAllocatedLines(row), + bom_items.length, + { + max_width: '150px', + } + ); + + return `
${progressBar}
`; + }, + sorter: function(value_a, value_b, row_a, row_b) { + var q_a = countAllocatedLines(row_a); + var q_b = countAllocatedLines(row_b); + + return q_a > q_b ? 1 : -1; + }, + }, + { + field: 'tests', + title: '{% trans "Completed Tests" %}', + sortable: true, + switchable: true, + formatter: function(value, row) { + if (part_tests == null || part_tests.length == 0) { + return `{% trans "No required tests for this build" %}`; + } + + var n_passed = countPassedTests(row); + + var progress = makeProgressBar( + n_passed, + part_tests.length, + { + max_width: '150px', + } + ); + + return progress; + }, + sorter: function(a, b, row_a, row_b) { + var n_a = countPassedTests(row_a); + var n_b = countPassedTests(row_b); + + return n_a > n_b ? 1 : -1; } }, { @@ -941,6 +1223,9 @@ function loadBuildOutputTable(build_info, options={}) { return makeBuildOutputButtons( row.pk, build_info, + { + has_bom_items: bom_items.length > 0, + } ); } } @@ -956,6 +1241,79 @@ function loadBuildOutputTable(build_info, options={}) { $(table).on('collapse-row.bs.table', function(detail, index, row) { $(`#button-output-allocate-${row.pk}`).prop('disabled', true); }); + + // Add callbacks for the various table menubar buttons + + // Complete multiple outputs + $('#multi-output-complete').click(function() { + var outputs = $(table).bootstrapTable('getSelections'); + + if (outputs.length == 0) { + outputs = $(table).bootstrapTable('getData'); + } + + completeBuildOutputs( + build_info.pk, + outputs, + { + success: function() { + // Reload the "in progress" table + $('#build-output-table').bootstrapTable('refresh'); + + // Reload the "completed" table + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); + + // Delete multiple build outputs + $('#multi-output-delete').click(function() { + var outputs = $(table).bootstrapTable('getSelections'); + + if (outputs.length == 0) { + outputs = $(table).bootstrapTable('getData'); + } + + deleteBuildOutputs( + build_info.pk, + outputs, + { + success: function() { + // Reload the "in progress" table + $('#build-output-table').bootstrapTable('refresh'); + + // Reload the "completed" table + $('#build-stock-table').bootstrapTable('refresh'); + } + } + ); + }); + + // Print stock item labels + $('#incomplete-output-print-label').click(function() { + var outputs = $(table).bootstrapTable('getSelections'); + + if (outputs.length == 0) { + outputs = $(table).bootstrapTable('getData'); + } + + var stock_id_values = []; + + outputs.forEach(function(output) { + stock_id_values.push(output.pk); + }); + + printStockItemLabels(stock_id_values); + }); + + $('#outputs-expand').click(function() { + $(table).bootstrapTable('expandAllRows'); + }); + + $('#outputs-collapse').click(function() { + $(table).bootstrapTable('collapseAllRows'); + }); } @@ -973,7 +1331,6 @@ function loadBuildOutputTable(build_info, options={}) { */ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { - var buildId = buildInfo.pk; var partId = buildInfo.part; @@ -985,6 +1342,26 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { outputId = 'untracked'; } + var bom_items = buildInfo.bom_items || null; + + // If BOM items have not been provided, load via the API + if (bom_items == null) { + inventreeGet( + '{% url "api-bom-list" %}', + { + part: partId, + sub_part_detail: true, + sub_part_trackable: buildInfo.tracked_parts, + }, + { + async: false, + success: function(results) { + bom_items = results; + } + } + ); + } + var table = options.table; if (options.table == null) { @@ -1002,13 +1379,72 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { setupFilterList('builditems', $(table), options.filterTarget); - // If an "output" is specified, then only "trackable" parts are allocated - // Otherwise, only "untrackable" parts are allowed - var trackable = ! !output; + var allocated_items = output == null ? null : output.allocations; - function reloadTable() { - // Reload the entire build allocation table - $(table).bootstrapTable('refresh'); + function redrawAllocationData() { + // Force a refresh of each row in the table + // Note we cannot call 'refresh' because we are passing data from memory + // var rows = $(table).bootstrapTable('getData'); + + // How many rows are fully allocated? + var allocated_rows = 0; + + bom_items.forEach(function(row) { + $(table).bootstrapTable('updateByUniqueId', row.pk, row, true); + + if (isRowFullyAllocated(row)) { + allocated_rows += 1; + } + }); + + // Find the top-level progess bar for this build output + var output_progress_bar = $(`#output-progress-${outputId}`); + + if (output_progress_bar.exists()) { + if (bom_items.length > 0) { + output_progress_bar.html( + makeProgressBar( + allocated_rows, + bom_items.length, + { + max_width: '150px', + } + ) + ); + } + } else { + console.warn(`Could not find progress bar for output '${outputId}'`); + } + } + + function reloadAllocationData(async=true) { + // Reload stock allocation data for this particular build output + + inventreeGet( + '{% url "api-build-item-list" %}', + { + build: buildId, + part_detail: true, + location_detail: true, + output: output == null ? null : output.pk, + }, + { + async: async, + success: function(response) { + allocated_items = response; + + redrawAllocationData(); + + } + } + ); + } + + if (allocated_items == null) { + // No allocation data provided? Request from server (blocking) + reloadAllocationData(false); + } else { + redrawAllocationData(); } function requiredQuantity(row) { @@ -1032,6 +1468,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } function availableQuantity(row) { + // Return the total available stock for a given row // Base stock var available = row.available_stock; @@ -1045,27 +1482,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } return available; - } - function sumAllocations(row) { - // Calculat total allocations for a given row - if (!row.allocations) { - row.allocated = 0; - return 0; - } - - var quantity = 0; - - row.allocations.forEach(function(item) { - quantity += item.quantity; - }); - - row.allocated = parseFloat(quantity.toFixed(15)); - + function allocatedQuantity(row) { + row.allocated = sumAllocationsForBomRow(row, allocated_items); return row.allocated; } + function isRowFullyAllocated(row) { + return allocatedQuantity(row) >= requiredQuantity(row); + } + function setupCallbacks() { // Register button callbacks once table data are loaded @@ -1079,7 +1506,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var row = $(table).bootstrapTable('getRowByUniqueId', pk); if (!row) { - console.log('WARNING: getRowByUniqueId returned null'); + console.warn('getRowByUniqueId returned null'); return; } @@ -1092,7 +1519,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { { source_location: buildInfo.source_location, success: function(data) { - $(table).bootstrapTable('refresh'); + // $(table).bootstrapTable('refresh'); + reloadAllocationData(); }, output: output == null ? null : output.pk, } @@ -1124,7 +1552,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { newBuildOrder({ part: pk, parent: buildId, - quantity: requiredQuantity(row) - sumAllocations(row), + quantity: requiredQuantity(row) - allocatedQuantity(row), }); }); @@ -1139,18 +1567,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { bom_item: row.pk, output: outputId == 'untracked' ? null : outputId, table: table, + onSuccess: function(response, opts) { + reloadAllocationData(); + } }); }); } // Load table of BOM items $(table).inventreeTable({ - url: '{% url "api-bom-list" %}', - queryParams: { - part: partId, - sub_part_detail: true, - sub_part_trackable: trackable, - }, + data: bom_items, disablePagination: true, formatNoMatches: function() { return '{% trans "No BOM items found" %}'; @@ -1162,124 +1588,11 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Setup button callbacks setupCallbacks(); }, - onLoadSuccess: function(tableData) { - // Once the BOM data are loaded, request allocation data for this build output - - var params = { - build: buildId, - part_detail: true, - location_detail: true, - }; - - if (output) { - params.sub_part_trackable = true; - params.output = outputId; - } else { - params.sub_part_trackable = false; - } - - inventreeGet('/api/build/item/', - params, - { - success: function(data) { - // Iterate through the returned data, and group by the part they point to - var allocations = {}; - - // Total number of line items - var totalLines = tableData.length; - - // Total number of "completely allocated" lines - var allocatedLines = 0; - - data.forEach(function(item) { - - // Group BuildItem objects by part - var part = item.bom_part || item.part; - var key = parseInt(part); - - if (!(key in allocations)) { - allocations[key] = []; - } - - allocations[key].push(item); - }); - - // Now update the allocations for each row in the table - for (var key in allocations) { - - // Select the associated row in the table - var tableRow = $(table).bootstrapTable('getRowByUniqueId', key); - - if (!tableRow) { - continue; - } - - // Set the allocation list for that row - tableRow.allocations = allocations[key]; - - // 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 >= requiredQuantity) { - allocatedLines += 1; - } - - // Push the updated row back into the main table - $(table).bootstrapTable('updateByUniqueId', key, tableRow, true); - } - - // Update any rows which we did not receive allocation information for - var td = $(table).bootstrapTable('getData'); - - td.forEach(function(tableRow) { - if (tableRow.allocations == null) { - - tableRow.allocations = []; - - $(table).bootstrapTable('updateByUniqueId', tableRow.pk, tableRow, true); - } - }); - - // Update the progress bar for this build output - var build_progress = $(`#output-progress-${outputId}`); - - if (build_progress.exists()) { - if (totalLines > 0) { - - var progress = makeProgressBar( - allocatedLines, - totalLines, - { - max_width: '150px', - } - ); - - build_progress.html(progress); - } else { - build_progress.html(''); - } - - } else { - console.log(`WARNING: Could not find progress bar for output ${outputId}`); - } - } - } - ); - }, sortable: true, showColumns: false, detailView: true, detailFilter: function(index, row) { - return row.allocations != null; + return allocatedQuantity(row) > 0; }, detailFormatter: function(index, row, element) { // Contruct an 'inner table' which shows which stock items have been allocated @@ -1293,7 +1606,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var subTable = $(`#${subTableId}`); subTable.bootstrapTable({ - data: row.allocations, + data: getAllocationsForBomRow(row, allocated_items), showHeader: true, columns: [ { @@ -1315,7 +1628,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { var url = ''; - var serial = row.serial; if (row.stock_item_detail) { @@ -1383,7 +1695,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity: {}, }, title: '{% trans "Edit Allocation" %}', - onSuccess: reloadTable, + onSuccess: reloadAllocationData, }); }); @@ -1393,7 +1705,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { constructForm(`/api/build/item/${pk}/`, { method: 'DELETE', title: '{% trans "Remove Allocation" %}', - onSuccess: reloadTable, + onSuccess: reloadAllocationData, }); }); }, @@ -1494,25 +1806,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { title: '{% trans "Allocated" %}', sortable: true, formatter: function(value, row) { - var allocated = 0; - - if (row.allocations != null) { - row.allocations.forEach(function(item) { - allocated += item.quantity; - }); - - var required = requiredQuantity(row); - - return makeProgressBar(allocated, required); - } else { - return `{% trans "loading" %}...`; - } + var allocated = allocatedQuantity(row); + var required = requiredQuantity(row); + return makeProgressBar(allocated, required); }, sorter: function(valA, valB, rowA, rowB) { // Custom sorting function for progress bars - var aA = sumAllocations(rowA); - var aB = sumAllocations(rowB); + var aA = allocatedQuantity(rowA); + var aB = allocatedQuantity(rowB); var qA = requiredQuantity(rowA); var qB = requiredQuantity(rowB); @@ -1532,12 +1834,12 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Handle the case where both ratios are equal if (progressA == progressB) { - return (qA < qB) ? 1 : -1; + return (qA > qB) ? 1 : -1; } if (progressA == progressB) return 0; - return (progressA < progressB) ? 1 : -1; + return (progressA > progressB) ? 1 : -1; } }, { @@ -1547,7 +1849,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { // Generate action buttons for this build output var html = `
`; - if (sumAllocations(row) < requiredQuantity(row)) { + if (allocatedQuantity(row) < requiredQuantity(row)) { if (row.sub_part_detail.assembly) { html += makeIconButton('fa-tools icon-blue', 'button-build', row.sub_part, '{% trans "Build stock" %}'); } @@ -1563,7 +1865,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { 'fa-minus-circle icon-red', 'button-unallocate', row.sub_part, '{% trans "Unallocate stock" %}', { - disabled: row.allocations == null + disabled: allocatedQuantity(row) == 0, } ); @@ -1672,7 +1974,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`); var html = ` - + ${thumb} ${sub_part.full_name} @@ -1762,8 +2064,6 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { method: 'POST', fields: {}, preFormContent: html, - confirm: true, - confirmMessage: '{% trans "Confirm stock allocation" %}', title: '{% trans "Allocate Stock Items to Build Order" %}', afterRender: function(fields, options) { @@ -1859,7 +2159,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { $(options.modal).find('.button-row-remove').click(function() { var pk = $(this).attr('pk'); - $(options.modal).find(`#allocation_row_${pk}`).remove(); + $(options.modal).find(`#items_${pk}`).remove(); }); }, onSubmit: function(fields, opts) { @@ -1974,7 +2274,9 @@ function autoAllocateStockToBuild(build_id, bom_items=[], options={}) { confirm: true, preFormContent: html, onSuccess: function(response) { - $('#allocation-table-untracked').bootstrapTable('refresh'); + if (options.onSuccess) { + options.onSuccess(response); + } } }); } @@ -2072,8 +2374,8 @@ function loadBuildTable(table, options) { } }, { - field: 'quantity', - title: '{% trans "Completed" %}', + field: 'completed', + title: '{% trans "Progress" %}', sortable: true, formatter: function(value, row) { return makeProgressBar( diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index c464ad3645..dc40d1e30c 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -163,27 +163,29 @@ function makeProgressBar(value, maximum, opts={}) { var style = options.style || ''; - var text = ''; + var text = options.text; + + if (!text) { + if (style == 'percent') { + // Display e.g. "50%" - if (style == 'percent') { - // Display e.g. "50%" + text = `${percent}%`; + } else if (style == 'max') { + // Display just the maximum value + text = `${maximum}`; + } else if (style == 'value') { + // Display just the current value + text = `${value}`; + } else if (style == 'blank') { + // No display! + text = ''; + } else { + /* Default style + * Display e.g. "5 / 10" + */ - text = `${percent}%`; - } else if (style == 'max') { - // Display just the maximum value - text = `${maximum}`; - } else if (style == 'value') { - // Display just the current value - text = `${value}`; - } else if (style == 'blank') { - // No display! - text = ''; - } else { - /* Default style - * Display e.g. "5 / 10" - */ - - text = `${value} / ${maximum}`; + text = `${value} / ${maximum}`; + } } var id = options.id || 'progress-bar'; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 656a3f9f63..b88de5af35 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -113,8 +113,6 @@ function renderStockItem(name, data, parameters={}, options={}) { } } - - var html = ` ${part_detail} @@ -146,7 +144,7 @@ function renderStockLocation(name, data, parameters={}, options={}) { html += ` - ${data.description}`; } - html += `{% trans "Location ID" %}: ${data.pk}`; + html += renderId('{% trans "Location ID" %}', data.pk, parameters); return html; } @@ -162,10 +160,9 @@ function renderBuild(name, data, parameters={}, options={}) { var html = select2Thumbnail(image); - html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - html += `{% trans "Build ID" %}: ${data.pk}`; + html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - html += `

${data.title}

`; + html += renderId('{% trans "Build ID" %}', data.pk, parameters); return html; } @@ -300,12 +297,9 @@ function renderSalesOrderShipment(name, data, parameters={}, options={}) { var so_prefix = global_settings.SALESORDER_REFERENCE_PREFIX; - var html = ` - ${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} - - {% trans "Shipment ID" %}: ${data.pk} - - `; + var html = `${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}`; + + html += renderId('{% trans "Shipment ID" %}', data.pk, parameters); return html; } @@ -323,7 +317,7 @@ function renderPartCategory(name, data, parameters={}, options={}) { html += ` - ${data.description}`; } - html += `{% trans "Category ID" %}: ${data.pk}`; + html += renderId('{% trans "Category ID" %}', data.pk, parameters); return html; } @@ -366,7 +360,7 @@ function renderManufacturerPart(name, data, parameters={}, options={}) { html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Manufacturer Part ID" %}: ${data.pk}`; + html += renderId('{% trans "Manufacturer Part ID" %}', data.pk, parameters); return html; } @@ -395,9 +389,7 @@ function renderSupplierPart(name, data, parameters={}, options={}) { html += ` ${data.supplier_detail.name} - ${data.SKU}`; html += ` - ${data.part_detail.full_name}`; - html += `{% trans "Supplier Part ID" %}: ${data.pk}`; - + html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters); return html; - }