From 4a843908ec9b74e076e860ea96d636e95d6c8749 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 21:56:04 +1000
Subject: [PATCH 01/47] Add customize option to hide admin link in user menu

---
 InvenTree/InvenTree/settings.py | 2 +-
 InvenTree/config_template.yaml  | 1 +
 InvenTree/templates/navbar.html | 5 +++--
 3 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 6e88fe8375..1446c33aad 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -915,7 +915,7 @@ PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugin
 PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5)  # how often should plugin loading be tried?
 PLUGIN_FILE_CHECKED = False                    # Was the plugin file checked?
 
-# user interface customization values
+# User interface customization values
 CUSTOMIZE = get_setting(
     'INVENTREE_CUSTOMIZE',
     CONFIG.get('customize', {}),
diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml
index b9d14c4d4b..c60a58d37f 100644
--- a/InvenTree/config_template.yaml
+++ b/InvenTree/config_template.yaml
@@ -193,3 +193,4 @@ static_root: '/home/inventree/data/static'
 #   login_message: InvenTree demo instance - <a href='https://inventree.readthedocs.io/en/latest/demo/'> Click here for login details</a>
 #   navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
 #   logo: logo.png
+#   hide_admin_link: true
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index d7d70db59f..9b5ddd89a7 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -6,9 +6,10 @@
 {% settings_value 'BARCODE_ENABLE' as barcodes %}
 {% settings_value 'STICKY_HEADER' user=request.user as sticky %}
 {% navigation_enabled as plugin_nav %}
-{% inventree_demo_mode as demo %}
+
 {% inventree_show_about user as show_about %}
 {% inventree_customize 'navbar_message' as navbar_message %}
+{% inventree_customize 'hide_admin_link' as hide_admin_link %}
 
 <nav class="navbar {% if sticky %}fixed-top{% endif %} navbar-expand-lg navbar-light">
   <div class="container-fluid">
@@ -132,7 +133,7 @@
         </a>
         <ul class='dropdown-menu dropdown-menu-end inventree-navbar-menu'>
           {% if user.is_authenticated %}
-          {% if user.is_staff and not demo %}
+          {% if user.is_staff and not hide_admin_link %}
           <li><a class='dropdown-item' href="{% url 'admin:index' %}"><span class="fas fa-user-shield"></span> {% trans "Admin" %}</a></li>
           {% endif %}
           <li><a class='dropdown-item' href="{% url 'settings' %}"><span class="fas fa-cog"></span> {% trans "Settings" %}</a></li>

From fc4de6c7b8962a42a93f2887743eb1d30376c56a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 22:00:58 +1000
Subject: [PATCH 02/47] Remove other demo references

---
 InvenTree/InvenTree/settings.py                  | 9 ---------
 InvenTree/part/templatetags/inventree_extras.py  | 7 -------
 InvenTree/templates/InvenTree/settings/user.html | 4 ++--
 InvenTree/templates/account/login.html           | 3 +--
 InvenTree/templates/base.html                    | 3 +--
 5 files changed, 4 insertions(+), 22 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 1446c33aad..8f7b9e2e35 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -62,12 +62,6 @@ DEBUG = _is_true(get_setting(
     CONFIG.get('debug', True)
 ))
 
-# Determine if we are running in "demo mode"
-DEMO_MODE = _is_true(get_setting(
-    'INVENTREE_DEMO',
-    CONFIG.get('demo', False)
-))
-
 DOCKER = _is_true(get_setting(
     'INVENTREE_DOCKER',
     False
@@ -217,9 +211,6 @@ MEDIA_URL = '/media/'
 if DEBUG:
     logger.info("InvenTree running with DEBUG enabled")
 
-if DEMO_MODE:
-    logger.warning("InvenTree running in DEMO mode")  # pragma: no cover
-
 logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
 logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
 
diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py
index 5701658087..889946ff19 100644
--- a/InvenTree/part/templatetags/inventree_extras.py
+++ b/InvenTree/part/templatetags/inventree_extras.py
@@ -160,13 +160,6 @@ def inventree_in_debug_mode(*args, **kwargs):
     return djangosettings.DEBUG
 
 
-@register.simple_tag()
-def inventree_demo_mode(*args, **kwargs):
-    """ Return True if the server is running in DEMO mode """
-
-    return djangosettings.DEMO_MODE
-
-
 @register.simple_tag()
 def inventree_show_about(user, *args, **kwargs):
     """ Return True if the about modal should be shown """
diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html
index 32bc4d43e7..d06df06d1e 100644
--- a/InvenTree/templates/InvenTree/settings/user.html
+++ b/InvenTree/templates/InvenTree/settings/user.html
@@ -13,8 +13,8 @@
 {% endblock %}
 
 {% block actions %}
-{% inventree_demo_mode as demo %}
-{% if not demo %}
+{% inventree_customize 'hide_password_reset' as hide_password_reset %}
+{% if hide_password_reset %}
 <div class='btn btn-outline-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
     <span class='fas fa-key'></span> {% trans "Set Password" %}
 </div>
diff --git a/InvenTree/templates/account/login.html b/InvenTree/templates/account/login.html
index fcdd08a23c..3e0865c4b9 100644
--- a/InvenTree/templates/account/login.html
+++ b/InvenTree/templates/account/login.html
@@ -12,7 +12,6 @@
 {% settings_value 'LOGIN_ENABLE_SSO' as enable_sso %}
 {% inventree_customize 'login_message' as login_message %}
 {% mail_configured as mail_conf %}
-{% inventree_demo_mode as demo %}
 
 <h1>{% trans "Sign In" %}</h1>
 
@@ -42,7 +41,7 @@ for a account and sign in below:{% endblocktrans %}</p>
   <div class="btn-group float-right" role="group">
     <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
   </div>
-  {% if mail_conf and enable_pwd_forgot and not demo %}
+  {% if mail_conf and enable_pwd_forgot %}
   <a class="" href="{% url 'account_reset_password' %}"><small>{% trans "Forgot Password?" %}</small></a>
   {% endif %}
 </form>
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 0188ecefa5..8c2a949353 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -8,7 +8,6 @@
 {% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
 {% settings_value "LABEL_ENABLE" with user=user as labels_enabled %}
 {% inventree_show_about user as show_about %}
-{% inventree_demo_mode as demo_mode %}
 
 <!DOCTYPE html>
 <html lang="en">
@@ -94,7 +93,7 @@
             {% block alerts %}
             <div class='notification-area' id='alerts'>
                 <!-- Div for displayed alerts -->
-                {% if server_restart_required and not demo_mode %}
+                {% if server_restart_required %}
                 <div id='alert-restart-server' class='alert alert-danger' role='alert'>
                     <span class='fas fa-server'></span>
                     <strong>{% trans "Server Restart Required" %}</strong>

From 06da120ef33b2682d02dd662946f2ee5b194d8a3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 22:03:55 +1000
Subject: [PATCH 03/47] Hide entry box for changing password

---
 InvenTree/InvenTree/forms.py                     | 4 ++--
 InvenTree/templates/InvenTree/settings/user.html | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index 02b993d31b..91863f04e2 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -150,13 +150,13 @@ class DeleteForm(forms.Form):
 
 
 class EditUserForm(HelperForm):
-    """ Form for editing user information
+    """
+    Form for editing user information
     """
 
     class Meta:
         model = User
         fields = [
-            'username',
             'first_name',
             'last_name',
         ]
diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html
index d06df06d1e..d3d1e35210 100644
--- a/InvenTree/templates/InvenTree/settings/user.html
+++ b/InvenTree/templates/InvenTree/settings/user.html
@@ -14,14 +14,14 @@
 
 {% block actions %}
 {% inventree_customize 'hide_password_reset' as hide_password_reset %}
-{% if hide_password_reset %}
+{% if not hide_password_reset %}
 <div class='btn btn-outline-primary' type='button' id='edit-password' title='{% trans "Change Password" %}'>
     <span class='fas fa-key'></span> {% trans "Set Password" %}
 </div>
+{% endif %}
 <div class='btn btn-primary' type='button' id='edit-user' title='{% trans "Edit User Information" %}'>
     <span class='fas fa-user-cog'></span> {% trans "Edit" %}
 </div>
-{% endif %}
 {% endblock %}
 
 {% block content %}

From 518e0743cbcd313a2aaa1a41e4863dbc52064feb Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 22:05:25 +1000
Subject: [PATCH 04/47] Updated config template

---
 InvenTree/config_template.yaml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml
index c60a58d37f..7ba5823386 100644
--- a/InvenTree/config_template.yaml
+++ b/InvenTree/config_template.yaml
@@ -194,3 +194,4 @@ static_root: '/home/inventree/data/static'
 #   navbar_message: <h6>InvenTree demo mode <a href='https://inventree.readthedocs.io/en/latest/demo/'><span class='fas fa-info-circle'></span></a></h6>
 #   logo: logo.png
 #   hide_admin_link: true
+#   hide_password_reset: true

From fc3b0621883ee67ca0a9ae4178126f9be477c097 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 22:09:25 +1000
Subject: [PATCH 05/47] Mark injected strings as safe

---
 InvenTree/templates/account/login.html | 2 +-
 InvenTree/templates/navbar.html        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/account/login.html b/InvenTree/templates/account/login.html
index 3e0865c4b9..042c119440 100644
--- a/InvenTree/templates/account/login.html
+++ b/InvenTree/templates/account/login.html
@@ -36,7 +36,7 @@ for a account and sign in below:{% endblocktrans %}</p>
 
   <hr>
   {% if login_message %}
-  <div>{{ login_message }}<hr></div>
+  <div>{{ login_message | safe }}<hr></div>
   {% endif %}
   <div class="btn-group float-right" role="group">
     <button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 9b5ddd89a7..4660123e0d 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -90,7 +90,7 @@
     {% if navbar_message %}
       {% include "spacer.html" %}
       <div class='flex justify-content-center'>
-        {{ navbar_message }}
+        {{ navbar_message | safe }}
       </div>
       {% include "spacer.html" %}
       {% include "spacer.html" %}

From e809fd9a2c0a91df2ff21171635de506e15c54d8 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 27 Apr 2022 23:07:26 +1000
Subject: [PATCH 06/47] Remove database metrics from system info tab

---
 InvenTree/templates/stats.html | 27 ---------------------------
 1 file changed, 27 deletions(-)

diff --git a/InvenTree/templates/stats.html b/InvenTree/templates/stats.html
index 7a22f88023..e1359e5886 100644
--- a/InvenTree/templates/stats.html
+++ b/InvenTree/templates/stats.html
@@ -87,31 +87,4 @@
     <!-- TODO - Enumerate system issues here! -->
     {% endfor %}
     {% endif %}
-
-    <tr>
-        <td colspan='3'><strong>{% trans "Parts" %}</strong></td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-sitemap'></span></td>
-        <td>{% trans "Part Categories" %}</td>
-        <td>{{ part_cat_count }}</td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-shapes'></span></td>
-        <td>{% trans "Parts" %}</td>
-        <td>{{ part_count }}</td>
-    </tr>
-    <tr>
-        <td colspan="3"><strong>{% trans "Stock Items" %}</strong></td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-map-marker-alt'></span></td>
-        <td>{% trans "Stock Locations" %}</td>
-        <td>{{ stock_loc_count }}</td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-boxes'></span></td>
-        <td>{% trans "Stock Items" %}</td>
-        <td>{{ stock_item_count }}</td>
-    </tr>
 </table>
\ No newline at end of file

From 4222b614fa6a5532b1e02b2442dceca0535128ea Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 01:20:36 +1000
Subject: [PATCH 07/47] Remove stat context variables

(cherry picked from commit 0989c308d0cea9b9405a1338d257b542c6d33d73)
---
 InvenTree/InvenTree/views.py | 23 -----------------------
 1 file changed, 23 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index feb586c844..3415a7363b 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -882,29 +882,6 @@ class DatabaseStatsView(AjaxView):
     ajax_template_name = "stats.html"
     ajax_form_title = _("System Information")
 
-    def get_context_data(self, **kwargs):
-
-        ctx = {}
-
-        # Part stats
-        ctx['part_count'] = Part.objects.count()
-        ctx['part_cat_count'] = PartCategory.objects.count()
-
-        # Stock stats
-        ctx['stock_item_count'] = StockItem.objects.count()
-        ctx['stock_loc_count'] = StockLocation.objects.count()
-
-        """
-        TODO: Other ideas for database metrics
-
-        - "Popular" parts (used to make other parts?)
-        - Most ordered part
-        - Most sold part
-        - etc etc etc
-        """
-
-        return ctx
-
 
 class NotificationsView(TemplateView):
     """ View for showing notifications

From 6e7b3074235bdb86938ef4f3e5ca3107d4613899 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 09:51:51 +1000
Subject: [PATCH 08/47] Prevent "null" from being displayed as part units

---
 InvenTree/templates/js/translated/part.js | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index d552bcb9d7..57f76dcae4 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -500,6 +500,11 @@ function duplicateBom(part_id, options={}) {
  */
 function partStockLabel(part, options={}) {
 
+    // Prevent literal string 'null' from being displayed
+    if (part.units == null) {
+        part.units = '';
+    }
+
     if (part.in_stock) {
         // There IS stock available for this part
 

From 823be74f38e423c024e522c140e5a5cd6a568711 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 12:53:16 +1000
Subject: [PATCH 09/47] PEP fixes

---
 InvenTree/InvenTree/views.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 3415a7363b..183e491580 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -34,8 +34,7 @@ from user_sessions.views import SessionDeleteView, SessionDeleteOtherView
 
 from common.settings import currency_code_default, currency_codes
 
-from part.models import Part, PartCategory
-from stock.models import StockLocation, StockItem
+from part.models import PartCategory
 from common.models import InvenTreeSetting, ColorTheme
 from users.models import check_user_role, RuleSet
 

From aec10e0cc79e8529aa96c4a9e5bf1d25609c0393 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 14:58:05 +1000
Subject: [PATCH 10/47] Fix part allocation check

---
 InvenTree/part/models.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 1edae69351..365ed62914 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -2740,8 +2740,8 @@ class BomItem(models.Model, DataImportMixin):
             if not p.active:
                 continue
 
-            # Trackable parts cannot be 'auto allocated'
-            if p.trackable:
+            # Trackable status must be the same as the sub_part
+            if p.trackable != self.sub_part.trackable:
                 continue
 
             valid_parts.append(p)

From ba3bcdba8916d93fac0c6e9b6d7d41caa67bf888 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 15:50:10 +1000
Subject: [PATCH 11/47] Add switchable columns to build output table

---
 InvenTree/templates/js/translated/build.js | 13 ++++++++-----
 1 file changed, 8 insertions(+), 5 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 65fc3a4d6c..8be3ee52ce 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -786,7 +786,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}`);
             }
         });
 
@@ -869,7 +869,7 @@ function loadBuildOutputTable(build_info, options={}) {
         url: '{% url "api-stock-list" %}',
         queryParams: filters,
         original: params,
-        showColumns: false,
+        showColumns: true,
         uniqueId: 'pk',
         name: 'build-outputs',
         sortable: true,
@@ -901,6 +901,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 +910,9 @@ function loadBuildOutputTable(build_info, options={}) {
             },
             {
                 field: 'quantity',
-                title: '{% trans "Quantity" %}',
+                title: '{% trans "Build Output" %}',
+                switchable: true,
+                sortable: true,
                 formatter: function(value, row) {
 
                     var url = `/stock/item/${row.pk}/`;
@@ -1079,7 +1082,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;
             }
 
@@ -1269,7 +1272,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                             }
     
                         } else {
-                            console.log(`WARNING: Could not find progress bar for output ${outputId}`);
+                            console.warn(`Could not find progress bar for output ${outputId}`);
                         }
                     }
                 }

From 63145c90b0c5fcbf0911065ea32209da980f7b09 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 16:14:23 +1000
Subject: [PATCH 12/47] Server-side sorting of "build output" column

---
 InvenTree/templates/js/translated/build.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 8be3ee52ce..115e55c902 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -913,6 +913,7 @@ function loadBuildOutputTable(build_info, options={}) {
                 title: '{% trans "Build Output" %}',
                 switchable: true,
                 sortable: true,
+                sortName: 'stock', // This will sort by quantity -> serial_int -> serial
                 formatter: function(value, row) {
 
                     var url = `/stock/item/${row.pk}/`;
@@ -926,7 +927,7 @@ function loadBuildOutputTable(build_info, options={}) {
                     }
 
                     return renderLink(text, url);
-                }
+                },
             },
             {
                 field: 'allocated',

From 2b46ed300e2e13784a6de6d96907c6ea91822517 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 16:18:40 +1000
Subject: [PATCH 13/47] Client side pagination and sorting

---
 InvenTree/templates/js/translated/build.js | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 115e55c902..5d0461861e 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -874,7 +874,7 @@ function loadBuildOutputTable(build_info, options={}) {
         name: 'build-outputs',
         sortable: true,
         search: false,
-        sidePagination: 'server',
+        sidePagination: 'client',
         detailView: has_tracked_items,
         detailFilter: function(index, row) {
             return true;
@@ -913,7 +913,6 @@ function loadBuildOutputTable(build_info, options={}) {
                 title: '{% trans "Build Output" %}',
                 switchable: true,
                 sortable: true,
-                sortName: 'stock', // This will sort by quantity -> serial_int -> serial
                 formatter: function(value, row) {
 
                     var url = `/stock/item/${row.pk}/`;
@@ -928,6 +927,21 @@ function loadBuildOutputTable(build_info, options={}) {
 
                     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',

From 6538ab86cb11c0e249b8861861bf4cb042ab3bed Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 16:25:04 +1000
Subject: [PATCH 14/47] Bug fix for 'required' filter in PartTestTemplate API

---
 InvenTree/part/api.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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
 

From d894302e6258779c378d12d0cd24363ac2361a8b Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 16:54:02 +1000
Subject: [PATCH 15/47] Request build output test result information

---
 InvenTree/templates/js/translated/build.js | 131 ++++++++++++++++++++-
 1 file changed, 128 insertions(+), 3 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 5d0461861e..6a4e7ac429 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -865,6 +865,87 @@ function loadBuildOutputTable(build_info, options={}) {
         );
     }
 
+    var part_tests = null;
+
+    function updateTestResultData(rows) {
+
+        console.log("updateTestResultData");
+
+        // 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,
+                },
+                {
+                    async: false,
+                    success: function(response) {
+                        // Save the list of part tests
+                        part_tests = response;
+
+                        updateTestResultData(rows);
+                    }
+                }
+            );;
+        }
+
+        rows.forEach(function(row) {
+
+            // Ignore if this row has already been updated (else, infinite loop!)
+            if (row.passed_tests) {
+                return;
+            }
+
+            // Request test result information for the particular build output
+            inventreeGet(
+                '{% url "api-stock-test-result-list" %}',
+                {
+                    stock_item: row.pk,
+                },
+                {
+                    success: function(results) {
+
+                        // A list of tests that this stock item has passed
+                        var passed_tests = {};
+
+                        // Keep a list of tests that this stock item has passed
+                        results.forEach(function(result) {
+                            if (result.result) {
+                                passed_tests[result.key] = true;
+                            }
+                        });
+
+                        row.passed_tests = passed_tests;
+
+                        $(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;
+    }
+
+    var table_loaded = false;
+
     $(table).inventreeTable({
         url: '{% url "api-stock-list" %}',
         queryParams: filters,
@@ -885,11 +966,21 @@ function loadBuildOutputTable(build_info, options={}) {
         formatNoMatches: function() {
             return '{% trans "No active build outputs found" %}';
         },
-        onPostBody: function() {
+        onPostBody: function(rows) {
+            console.log("onPostBody");
             // Add callbacks for the buttons
             setupBuildOutputButtonCallbacks();
+        },
+        onLoadSuccess: function(rows) {
 
-            $(table).bootstrapTable('expandAllRows');
+            console.log("onLoadSuccess");
+
+            updateTestResultData(rows);
+        },
+        onRefresh: function() {
+            console.log("onRefresh");
+            // var rows = $(table).bootstrapTable('getData');
+            // updateTestResultData(rows);
         },
         columns: [
             {
@@ -945,10 +1036,44 @@ function loadBuildOutputTable(build_info, options={}) {
             },
             {
                 field: 'allocated',
-                title: '{% trans "Allocated Parts" %}',
+                title: '{% trans "Allocated Stock" %}',
                 visible: has_tracked_items,
+                switchable: false,
                 formatter: function(value, row) {
                     return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;
+                },
+                sorter: function(value_a, value_b, row_a, row_b) {
+                    // TODO: Custom sorter for "allocated stock" column
+                    return 0;
+                },
+            },
+            {
+                field: 'tests',
+                title: '{% trans "Tests" %}',
+                sortable: false,
+                switchable: true,
+                formatter: function(value, row) {
+                    if (part_tests == null || part_tests.length == 0) {
+                        return `<em>{% trans "No tests found " %}</em>`;
+                    }
+
+                    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;
                 }
             },
             {

From a48c8025760cb38c2a444ebeea1cf398adcd38b1 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 16:54:29 +1000
Subject: [PATCH 16/47] Sort by test status

---
 InvenTree/templates/js/translated/build.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 6a4e7ac429..111cb51b15 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1050,7 +1050,7 @@ function loadBuildOutputTable(build_info, options={}) {
             {
                 field: 'tests',
                 title: '{% trans "Tests" %}',
-                sortable: false,
+                sortable: true,
                 switchable: true,
                 formatter: function(value, row) {
                     if (part_tests == null || part_tests.length == 0) {

From 74a08be5be0759e28629e478e0cf5401a358be7f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 17:00:23 +1000
Subject: [PATCH 17/47] Load (and cache) tracked BOM items for this build
 output

---
 InvenTree/templates/js/translated/build.js | 46 +++++++++++++++++++---
 1 file changed, 40 insertions(+), 6 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 111cb51b15..f7399a50df 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -865,9 +865,44 @@ function loadBuildOutputTable(build_info, options={}) {
         );
     }
 
+    // List of "tracked bom items" required for this build order
+    var bom_items = null;
+
+    function updateAllocationData(rows) {
+        // Update stock allocation information for the build outputs
+
+        console.log("updateAllocationData");
+
+        // Request list of BOM data for this build order
+        if (bom_items == null) {
+            inventreeGet(
+                '{% url "api-bom-list" %}',
+                {
+                    part: build_info.part,
+                    sub_part_detail: true,
+                    sub_part_trackable: true,
+                },
+                {
+                    success: function(response) {
+                        // Save the BOM items
+                        bom_items = response;
+
+                        // Callback to this function again
+                        updateAllocationData(rows);
+                    }
+                }
+            );
+
+            return;
+        }
+
+        console.log("BOM ITEMS:", bom_items);
+    }
+
     var part_tests = null;
 
     function updateTestResultData(rows) {
+        // Update test result information for the build outputs
 
         console.log("updateTestResultData");
 
@@ -880,15 +915,17 @@ function loadBuildOutputTable(build_info, options={}) {
                     required: true,
                 },
                 {
-                    async: false,
                     success: function(response) {
                         // Save the list of part tests
                         part_tests = response;
 
+                        // Callback to this function again
                         updateTestResultData(rows);
                     }
                 }
-            );;
+            );
+
+            return;
         }
 
         rows.forEach(function(row) {
@@ -944,8 +981,6 @@ function loadBuildOutputTable(build_info, options={}) {
         return n;
     }
 
-    var table_loaded = false;
-
     $(table).inventreeTable({
         url: '{% url "api-stock-list" %}',
         queryParams: filters,
@@ -975,12 +1010,11 @@ function loadBuildOutputTable(build_info, options={}) {
 
             console.log("onLoadSuccess");
 
+            updateAllocationData(rows);
             updateTestResultData(rows);
         },
         onRefresh: function() {
             console.log("onRefresh");
-            // var rows = $(table).bootstrapTable('getData');
-            // updateTestResultData(rows);
         },
         columns: [
             {

From e6c95bf6b21f219a5e6b7d96ba493fa95a385570 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 17:08:43 +1000
Subject: [PATCH 18/47] Cache tracked BOM items for the build order

---
 InvenTree/templates/js/translated/build.js | 50 +++++++++++++---------
 1 file changed, 30 insertions(+), 20 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index f7399a50df..f8872f12a5 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -841,6 +841,9 @@ function loadBuildOutputTable(build_info, options={}) {
         });
     }
 
+    // List of "tracked bom items" required for this build order
+    var bom_items = null;
+
     /*
      * Construct a "sub table" showing the required BOM items
      */
@@ -855,6 +858,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,14 +871,9 @@ function loadBuildOutputTable(build_info, options={}) {
         );
     }
 
-    // List of "tracked bom items" required for this build order
-    var bom_items = null;
-
     function updateAllocationData(rows) {
         // Update stock allocation information for the build outputs
 
-        console.log("updateAllocationData");
-
         // Request list of BOM data for this build order
         if (bom_items == null) {
             inventreeGet(
@@ -896,7 +897,9 @@ function loadBuildOutputTable(build_info, options={}) {
             return;
         }
 
-        console.log("BOM ITEMS:", bom_items);
+        rows.forEach(function(row) {
+
+        })
     }
 
     var part_tests = null;
@@ -904,8 +907,6 @@ function loadBuildOutputTable(build_info, options={}) {
     function updateTestResultData(rows) {
         // Update test result information for the build outputs
 
-        console.log("updateTestResultData");
-
         // Request test template data if it has not already been retrieved
         if (part_tests == null) {
             inventreeGet(
@@ -1002,20 +1003,14 @@ function loadBuildOutputTable(build_info, options={}) {
             return '{% trans "No active build outputs found" %}';
         },
         onPostBody: function(rows) {
-            console.log("onPostBody");
             // Add callbacks for the buttons
             setupBuildOutputButtonCallbacks();
         },
         onLoadSuccess: function(rows) {
 
-            console.log("onLoadSuccess");
-
             updateAllocationData(rows);
             updateTestResultData(rows);
         },
-        onRefresh: function() {
-            console.log("onRefresh");
-        },
         columns: [
             {
                 title: '',
@@ -1320,14 +1315,29 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         });
     }
 
+    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: trackable,
+            },
+            {
+                async: false,
+                success: function(results) {
+                    bom_items = results;
+                }
+            }
+        );
+    }
+
     // 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" %}';

From 0841c628e0ba8626aad14882e76753ede63b0c8c Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 17:27:09 +1000
Subject: [PATCH 19/47] Adds ability to filter build items by "tracked" flag

---
 InvenTree/build/api.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 33f3f4ab36..2e2fb53510 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -442,6 +442,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 = queryest.filter(install_into=None)
+
         # Filter by output target
         output = params.get('output', None)
 

From b8f274c680131247cf4a3ff9e59f2421f1c50bec Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 17:38:08 +1000
Subject: [PATCH 20/47] Request allocations for entire build, and cache

---
 InvenTree/templates/js/translated/build.js | 73 ++++++++++++++++++++--
 1 file changed, 68 insertions(+), 5 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index f8872f12a5..aa35d4c011 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -897,9 +897,46 @@ function loadBuildOutputTable(build_info, options={}) {
             return;
         }
 
-        rows.forEach(function(row) {
+        // 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);
+
+                        console.log("Updating row for stock item", row.pk);
+                    });
+                }
+            }
+        )
     }
 
     var part_tests = null;
@@ -1145,7 +1182,6 @@ function loadBuildOutputTable(build_info, options={}) {
  */
 function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
     
-
     var buildId = buildInfo.pk;
     var partId = buildInfo.part;
 
@@ -1178,6 +1214,30 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
     // Otherwise, only "untrackable" parts are allowed
     var trackable = ! !output;
 
+    var allocated_items = output == null ? null : output.allocations;
+
+    if (allocated_items == null) {
+
+        inventreeGet(
+            '{% url "api-build-item-list" %}',
+            {
+                build: buildId,
+                part_detail: true,
+                location_detail: true,
+                output: output == null ? null : output.pk,
+            },
+            {
+                async: false,
+                success: function(response) {
+                    allocated_items = response;
+                }
+            }
+        );
+    }
+
+    console.log("rendering table for output:", outputId);
+    console.log("allocations:", allocated_items);
+
     function reloadTable() {
         // Reload the entire build allocation table
         $(table).bootstrapTable('refresh');
@@ -1348,10 +1408,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         onPostBody: function(data) {
             // Setup button callbacks
             setupCallbacks();
-        },
-        onLoadSuccess: function(tableData) {
+        // },
+        // onLoadSuccess: function(tableData) {
             // Once the BOM data are loaded, request allocation data for this build output
 
+            console.log("old onLoadSuccess");
+            return;
+
             var params = {
                 build: buildId,
                 part_detail: true,

From 72bcea2f6dac73851a5c4da95c2d6aab01b60e38 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 17:53:27 +1000
Subject: [PATCH 21/47] Better caching and rendering of sub tables for
 particular build outputs

---
 InvenTree/templates/js/translated/build.js | 172 ++++-----------------
 1 file changed, 28 insertions(+), 144 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index aa35d4c011..820f78f47b 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -931,8 +931,6 @@ function loadBuildOutputTable(build_info, options={}) {
                     rows.forEach(function(row) {
                         row.allocations = allocations[row.pk] || [];
                         $(table).bootstrapTable('updateByUniqueId', row.pk, row, true);
-
-                        console.log("Updating row for stock item", row.pk);
                     });
                 }
             }
@@ -1280,24 +1278,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
 
     }
 
-    function sumAllocations(row) {
-        // Calculat total allocations for a given row
-        if (!row.allocations) {
-            row.allocated = 0;
-            return 0;
-        }
+    function getAllocationsForRow(row) {
+        var part_id = row.sub_part;
 
-        var quantity = 0;
+        var allocations = [];
 
-        row.allocations.forEach(function(item) {
-            quantity += item.quantity;
+        allocated_items.forEach(function(allocation) {
+            if (allocation.bom_part == part_id) {
+                allocations.push(allocation);
+            }
         });
 
-        row.allocated = parseFloat(quantity.toFixed(15));
+        return allocations;
+    }
+
+    function sumAllocations(row) {
+        
+        var allocated_quantity = 0;
+
+        getAllocationsForRow(row).forEach(function(allocation) {
+            allocated_quantity += allocation.quantity;
+        });
+        
+        row.allocated = parseFloat(allocated_quantity.toFixed(15));
 
         return row.allocated;
     }
 
+    function isRowFullyAllocated(row) {
+        return sumAllocations(row) >= requiredQuantity(row);
+    }
+
     function setupCallbacks() {
         // Register button callbacks once table data are loaded
 
@@ -1408,128 +1419,12 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         onPostBody: function(data) {
             // Setup button callbacks
             setupCallbacks();
-        // },
-        // onLoadSuccess: function(tableData) {
-            // Once the BOM data are loaded, request allocation data for this build output
-
-            console.log("old onLoadSuccess");
-            return;
-
-            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.warn(`Could not find progress bar for output ${outputId}`);
-                        }
-                    }
-                }
-            );
         },
         sortable: true,
         showColumns: false,
         detailView: true,
         detailFilter: function(index, row) {
-            return row.allocations != null;
+            return sumAllocations(row) > 0;
         },
         detailFormatter: function(index, row, element) {
             // Contruct an 'inner table' which shows which stock items have been allocated
@@ -1543,7 +1438,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
             var subTable = $(`#${subTableId}`);
 
             subTable.bootstrapTable({
-                data: row.allocations,
+                data: getAllocationsForRow(row),
                 showHeader: true,
                 columns: [
                     {
@@ -1565,7 +1460,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
 
                             var url = '';
 
-
                             var serial = row.serial;
 
                             if (row.stock_item_detail) {
@@ -1744,19 +1638,9 @@ 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 `<em>{% trans "loading" %}...</em><span class='fas fa-spinner fa-spin float-right'></span>`;
-                    }
+                    var allocated = sumAllocations(row);
+                    var required = requiredQuantity(row)
+                    return makeProgressBar(allocated, required);
                 },
                 sorter: function(valA, valB, rowA, rowB) {
                     // Custom sorting function for progress bars

From 8077469a3f5aa9f0ecb95d1994720328ae868bed Mon Sep 17 00:00:00 2001
From: Paul R <exp@users.noreply.github.com>
Date: Thu, 28 Apr 2022 10:32:13 +0100
Subject: [PATCH 22/47] [#2885] Support multiple '~' placeholders in serial
 numbers

---
 InvenTree/InvenTree/helpers.py | 5 +++--
 InvenTree/InvenTree/tests.py   | 5 +++++
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index c5a8ad4b67..b94383c874 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -427,8 +427,9 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
     serials = serials.strip()
 
     # fill in the next serial number into the serial
-    if '~' in serials:
-        serials = serials.replace('~', str(next_number))
+    while '~' in serials:
+        serials = serials.replace('~', str(next_number), 1)
+        next_number += 1
 
     # Split input string by whitespace or comma (,) characters
     groups = re.split("[\s,]+", serials)
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index 669628bdea..11ca990b8e 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -252,6 +252,11 @@ class TestSerialNumberExtraction(TestCase):
         sn = e("1, 2, 3, 4, 5", 5, 1)
         self.assertEqual(len(sn), 5)
 
+        # Test multiple placeholders
+        sn = e("1 2 ~ ~ ~", 5, 3)
+        self.assertEqual(len(sn), 5)
+        self.assertEqual(sn, [1, 2, 3, 4, 5])
+
         sn = e("1-5, 10-15", 11, 1)
         self.assertIn(3, sn)
         self.assertIn(13, sn)

From 1dba9f66fb4fd4787c1619f3c7431e087e8c3cfc Mon Sep 17 00:00:00 2001
From: Paul R <exp@users.noreply.github.com>
Date: Thu, 28 Apr 2022 10:34:01 +0100
Subject: [PATCH 23/47] [#2885] Support partial sequences in serial nos ('1, 2,
 3+')

---
 InvenTree/InvenTree/helpers.py | 16 ++++++++--------
 InvenTree/InvenTree/tests.py   |  5 +++++
 2 files changed, 13 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index b94383c874..45030ec0d7 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -439,6 +439,12 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
 
     # Helper function to check for duplicated numbers
     def add_sn(sn):
+        # Attempt integer conversion first, so numerical strings are never stored
+        try:
+            sn = int(sn)
+        except ValueError:
+            pass
+
         if sn in numbers:
             errors.append(_('Duplicate serial: {sn}').format(sn=sn))
         else:
@@ -496,7 +502,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
 
                 # case 1
                 else:
-                    end = start + expected_quantity
+                    end = start + (expected_quantity - len(numbers))
 
                 for n in range(start, end):
                     add_sn(n)
@@ -506,13 +512,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
 
         # At this point, we assume that the "group" is just a single serial value
         elif group:
-
-            try:
-                # First attempt to add as an integer value
-                add_sn(int(group))
-            except (ValueError):
-                # As a backup, add as a string value
-                add_sn(group)
+            add_sn(group)
 
         # No valid input group detected
         else:
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index 11ca990b8e..b9e9dd5228 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -252,6 +252,11 @@ class TestSerialNumberExtraction(TestCase):
         sn = e("1, 2, 3, 4, 5", 5, 1)
         self.assertEqual(len(sn), 5)
 
+        # Test partially specifying serials
+        sn = e("1, 2, 4+", 5, 1)
+        self.assertEqual(len(sn), 5)
+        self.assertEqual(sn, [1, 2, 4, 5, 6])
+
         # Test multiple placeholders
         sn = e("1 2 ~ ~ ~", 5, 3)
         self.assertEqual(len(sn), 5)

From b08efa4de7217dc9a2fcd81cc4879178cfaf872e Mon Sep 17 00:00:00 2001
From: Paul R <exp@users.noreply.github.com>
Date: Thu, 28 Apr 2022 10:38:04 +0100
Subject: [PATCH 24/47] [#2885] Don't interpolate serial groups if they are not
 numeric

---
 InvenTree/InvenTree/helpers.py |  5 +++--
 InvenTree/InvenTree/tests.py   | 14 ++++++++++++++
 2 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 45030ec0d7..68f42bab87 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -466,7 +466,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
         if '-' in group:
             items = group.split('-')
 
-            if len(items) == 2:
+            if len(items) == 2 and all([i.isnumeric() for i in items]):
                 a = items[0].strip()
                 b = items[1].strip()
 
@@ -484,7 +484,8 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
                     errors.append(_("Invalid group: {g}").format(g=group))
                     continue
             else:
-                errors.append(_("Invalid group: {g}").format(g=group))
+                # More than 2 hyphens or non-numeric group so add without interpolating
+                add_sn(group)
 
         # plus signals either
         # 1:  'start+':  expected number of serials, starting at start
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index b9e9dd5228..51e5527535 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -257,6 +257,16 @@ class TestSerialNumberExtraction(TestCase):
         self.assertEqual(len(sn), 5)
         self.assertEqual(sn, [1, 2, 4, 5, 6])
 
+        # Test groups are not interpolated with more than one hyphen in a word
+        sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
+        self.assertEqual(len(sn), 5)
+        self.assertEqual(sn, [1, 2, "TG-4SR-92", 4, 5])
+
+        # Test groups are not interpolated with alpha characters
+        sn = e("1, A-2, 3+", 5, 1)
+        self.assertEqual(len(sn), 5)
+        self.assertEqual(sn, [1, "A-2", 3, 4, 5])
+
         # Test multiple placeholders
         sn = e("1 2 ~ ~ ~", 5, 3)
         self.assertEqual(len(sn), 5)
@@ -317,6 +327,10 @@ class TestSerialNumberExtraction(TestCase):
         with self.assertRaises(ValidationError):
             e("10, a, 7-70j", 4, 1)
 
+        # Test groups are not interpolated with word characters
+        with self.assertRaises(ValidationError):
+            e("1, 2, 3, E-5", 5, 1)
+
     def test_combinations(self):
         e = helpers.extract_serial_numbers
 

From 82b32fe30e61bc9613d7a4e047864ad0ef491437 Mon Sep 17 00:00:00 2001
From: Paul R <exp@users.noreply.github.com>
Date: Thu, 28 Apr 2022 10:38:34 +0100
Subject: [PATCH 25/47] [#2885] Support hyphens in serials when correct no of
 serials supplied

---
 InvenTree/InvenTree/helpers.py | 12 +++++++++++-
 InvenTree/InvenTree/tests.py   |  5 +++++
 2 files changed, 16 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 68f42bab87..675898e3d0 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -458,8 +458,18 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
     if len(serials) == 0:
         raise ValidationError([_("Empty serial number string")])
 
-    for group in groups:
+    # If the user has supplied the correct number of serials, don't process them for groups
+    # just add them so any duplicates (or future validations) are checked
+    if len(groups) == expected_quantity:
+        for group in groups:
+            add_sn(group)
 
+        if len(errors) > 0:
+            raise ValidationError(errors)
+
+        return numbers
+
+    for group in groups:
         group = group.strip()
 
         # Hyphen indicates a range of numbers
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index 51e5527535..13f9198d92 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -257,6 +257,11 @@ class TestSerialNumberExtraction(TestCase):
         self.assertEqual(len(sn), 5)
         self.assertEqual(sn, [1, 2, 4, 5, 6])
 
+        # Test groups are not interpolated if enough serials are supplied
+        sn = e("1, 2, 3, AF5-69H, 5", 5, 1)
+        self.assertEqual(len(sn), 5)
+        self.assertEqual(sn, [1, 2, 3, "AF5-69H", 5])
+
         # Test groups are not interpolated with more than one hyphen in a word
         sn = e("1, 2, TG-4SR-92, 4+", 5, 1)
         self.assertEqual(len(sn), 5)

From 9ce2eb988f5c9d82e30a4244c07165fb8bbd63dc Mon Sep 17 00:00:00 2001
From: Paul R <exp@users.noreply.github.com>
Date: Thu, 28 Apr 2022 11:32:20 +0100
Subject: [PATCH 26/47] [#2885] Modify group error messages to be unique

---
 InvenTree/InvenTree/helpers.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 675898e3d0..7bd4fd819d 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -488,7 +488,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
                         for n in range(a, b + 1):
                             add_sn(n)
                     else:
-                        errors.append(_("Invalid group: {g}").format(g=group))
+                        errors.append(_("Invalid group range: {g}").format(g=group))
 
                 except ValueError:
                     errors.append(_("Invalid group: {g}").format(g=group))
@@ -519,7 +519,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
                     add_sn(n)
             # no case
             else:
-                errors.append(_("Invalid group: {g}").format(g=group))
+                errors.append(_("Invalid group sequence: {g}").format(g=group))
 
         # At this point, we assume that the "group" is just a single serial value
         elif group:

From f531e354b95ace9e0673a9b3c27756497cdf7c80 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 21:54:38 +1000
Subject: [PATCH 27/47] Allow completion of partially allocated build outputs

- Requires manual acceptance from user
---
 InvenTree/build/api.py                     |  2 +-
 InvenTree/build/serializers.py             | 15 ++++++++++++++-
 InvenTree/templates/js/translated/build.js |  6 ++++--
 3 files changed, 19 insertions(+), 4 deletions(-)

diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 2e2fb53510..22dd6473ab 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -452,7 +452,7 @@ class BuildItemList(generics.ListCreateAPIView):
             if tracked:
                 queryset = queryset.exclude(install_into=None)
             else:
-                queryset = queryest.filter(install_into=None)
+                queryset = queryset.filter(install_into=None)
 
         # Filter by output target
         output = params.get('output', None)
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index 4b55182563..7df2c474a9 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,
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 820f78f47b..29683154fe 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -427,6 +427,8 @@ function completeBuildOutputs(build_id, outputs, options={}) {
         fields: {
             status: {},
             location: {},
+            notes: {},
+            accept_incomplete_allocation: {},
         },
         confirm: true,
         title: '{% trans "Complete Build Outputs" %}',
@@ -445,6 +447,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 = [];
@@ -1896,8 +1900,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) {
 

From b63352ce200ce60f15a773bbd73644f296c7c16c Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 22:58:58 +1000
Subject: [PATCH 28/47] Add a stock item transaction when installing items into
 a build output

---
 InvenTree/build/models.py | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index e5189e6073..61783fc8fb 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(

From cb7c4396fb160043a0946ee1bea15721f875363b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 22:59:24 +1000
Subject: [PATCH 29/47] Refactor build page template

- Only load build outputs table as required
---
 InvenTree/build/templates/build/detail.html | 177 ++++++++++----------
 InvenTree/templates/js/translated/build.js  |  16 +-
 2 files changed, 89 insertions(+), 104 deletions(-)

diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index 92e1177e0f..c01c9055f1 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -401,110 +401,107 @@ 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',
-                ]
-            );
+    linkButtonsToSelection(
+        '#build-output-table',
+        [
+            '#output-options',
+            '#multi-output-complete',
+            '#multi-output-delete',
+        ]
+    );
 
-            $('#multi-output-complete').click(function() {
-                var outputs = $('#build-output-table').bootstrapTable('getSelections');
+    $('#multi-output-complete').click(function() {
+        var outputs = $('#build-output-table').bootstrapTable('getSelections');
 
-                completeBuildOutputs(
-                    build_info.pk,
-                    outputs,
-                    {
-                        success: function() {
-                            // Reload the "in progress" table
-                            $('#build-output-table').bootstrapTable('refresh');
+        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');
-                        }
-                    }
-                );
-            });
-
-            $('#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');
+                    // Reload the "completed" table
+                    $('#build-stock-table').bootstrapTable('refresh');
                 }
+            }
+        );
+    });
 
-                var stock_id_values = [];
+    $('#multi-output-delete').click(function() {
+        var outputs = $('#build-output-table').bootstrapTable('getSelections');
 
-                outputs.forEach(function(output) {
-                    stock_id_values.push(output.pk);
-                });
+        deleteBuildOutputs(
+            build_info.pk,
+            outputs,
+            {
+                success: function() {
+                    // Reload the "in progress" table
+                    $('#build-output-table').bootstrapTable('refresh');
 
-                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,
+                    // Reload the "completed" table
+                    $('#build-stock-table').bootstrapTable('refresh');
                 }
-            );
-            {% endif %}
+            }
+        )
+    });
+
+    $('#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 %}
+
+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,
+};
+
+// Load allocation table for un-tracked parts
+loadBuildOutputAllocationTable(
+    build_info,
+    null,
+    {
+        search: true,
     }
 );
+{% endif %}
 
 $('#btn-create-output').click(function() {
 
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 29683154fe..b90837a231 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -741,18 +741,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) {
@@ -1031,7 +1019,7 @@ function loadBuildOutputTable(build_info, options={}) {
         sortable: true,
         search: false,
         sidePagination: 'client',
-        detailView: has_tracked_items,
+        detailView: true,
         detailFilter: function(index, row) {
             return true;
         },
@@ -1105,7 +1093,7 @@ function loadBuildOutputTable(build_info, options={}) {
             {
                 field: 'allocated',
                 title: '{% trans "Allocated Stock" %}',
-                visible: has_tracked_items,
+                visible: true,
                 switchable: false,
                 formatter: function(value, row) {
                     return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;

From 4a81c058d60c46978ace380ae90d16fd3fc6a0df Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 23:14:37 +1000
Subject: [PATCH 30/47] Function to reload allocation table data

---
 InvenTree/templates/js/translated/build.js | 41 ++++++++++++++++++----
 1 file changed, 35 insertions(+), 6 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index b90837a231..7705bfaa16 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -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');
             }
@@ -1206,7 +1208,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
 
     var allocated_items = output == null ? null : output.allocations;
 
-    if (allocated_items == null) {
+    function reloadAllocationData(async=true) {
+        // Reload stock allocation data for this particular build output
 
         inventreeGet(
             '{% url "api-build-item-list" %}',
@@ -1217,16 +1220,37 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 output: output == null ? null : output.pk,
             },
             {
-                async: false,
+                async: async,
                 success: function(response) {
                     allocated_items = response;
+
+                    if (async) {
+
+                        // 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;
+
+                        rows.forEach(function(row) {
+                            $(table).bootstrapTable('updateByUniqueId', row.pk, row, true);
+
+                            if (isRowFullyAllocated(row)) {
+                                allocated_rows += 1;
+                            }
+                        });
+                    }
                 }
             }
         );
     }
 
-    console.log("rendering table for output:", outputId);
-    console.log("allocations:", allocated_items);
+    if (allocated_items == null) {
+
+        // No allocation data provided? Request from server (blocking)
+        reloadAllocationData(false);
+    }
 
     function reloadTable() {
         // Reload the entire build allocation table
@@ -1254,6 +1278,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;
@@ -1327,7 +1352,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,
                 }
@@ -1374,6 +1400,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 bom_item: row.pk,
                 output: outputId == 'untracked' ? null : outputId,
                 table: table,
+                onSuccess: function(response, opts) {
+                    reloadAllocationData();
+                }
             });
         });
     }

From df9a33935def45d7a21406209e60713363a3d75f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 28 Apr 2022 23:26:08 +1000
Subject: [PATCH 31/47] Row button fixes

---
 InvenTree/templates/js/translated/build.js | 53 +++++++++++-----------
 1 file changed, 27 insertions(+), 26 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 7705bfaa16..3376cb21a0 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -838,6 +838,23 @@ 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
      */
@@ -868,29 +885,6 @@ function loadBuildOutputTable(build_info, options={}) {
     function updateAllocationData(rows) {
         // Update stock allocation information for the build outputs
 
-        // Request list of BOM data for this build order
-        if (bom_items == null) {
-            inventreeGet(
-                '{% url "api-bom-list" %}',
-                {
-                    part: build_info.part,
-                    sub_part_detail: true,
-                    sub_part_trackable: true,
-                },
-                {
-                    success: function(response) {
-                        // Save the BOM items
-                        bom_items = response;
-
-                        // Callback to this function again
-                        updateAllocationData(rows);
-                    }
-                }
-            );
-
-            return;
-        }
-
         // Request updated stock allocation data for this build order
         inventreeGet(
             '{% url "api-build-item-list" %}',
@@ -1098,6 +1092,13 @@ function loadBuildOutputTable(build_info, options={}) {
                 visible: true,
                 switchable: false,
                 formatter: function(value, row) {
+                    // Display a progress bar which shows how many rows have been allocated
+                    var n_bom_lines = 0;
+                    
+                    if (bom_items) {
+                        n_bom_lines = bom_items.length;
+                    }
+                    return `lines: ${n_bom_lines}`;
                     return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;
                 },
                 sorter: function(value_a, value_b, row_a, row_b) {
@@ -1548,7 +1549,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                         quantity: {},
                     },
                     title: '{% trans "Edit Allocation" %}',
-                    onSuccess: reloadTable,
+                    onSuccess: reloadAllocationData,
                 });
             });
 
@@ -1558,7 +1559,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 constructForm(`/api/build/item/${pk}/`, {
                     method: 'DELETE',
                     title: '{% trans "Remove Allocation" %}',
-                    onSuccess: reloadTable,
+                    onSuccess: reloadAllocationData,
                 });
             });
         },
@@ -1718,7 +1719,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                         'fa-minus-circle icon-red', 'button-unallocate', row.sub_part,
                         '{% trans "Unallocate stock" %}',
                         {
-                            disabled: row.allocations == null
+                            disabled: sumAllocations(row) == 0,
                         }
                     );
 

From 6c6ebe70c27a940f22bcfaa89fbb94fac60846f4 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 00:27:27 +1000
Subject: [PATCH 32/47] Update progress bars for build output allocation

---
 InvenTree/templates/js/translated/build.js   | 227 ++++++++++++-------
 InvenTree/templates/js/translated/helpers.js |  40 ++--
 2 files changed, 171 insertions(+), 96 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 3376cb21a0..2c0b56c518 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -726,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.
  *
@@ -919,6 +948,32 @@ function loadBuildOutputTable(build_info, options={}) {
                     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',
+                                        }
+                                    )
+                                );
+                            }
+                        });
                     });
                 }
             }
@@ -1092,14 +1147,33 @@ function loadBuildOutputTable(build_info, options={}) {
                 visible: true,
                 switchable: false,
                 formatter: function(value, row) {
-                    // Display a progress bar which shows how many rows have been allocated
-                    var n_bom_lines = 0;
+
+                    // Display a progress bar which shows how many BOM lines have been fully allocated
+                    var n_bom_lines = 1;
+                    var n_completed_lines = 0;
                     
-                    if (bom_items) {
+                    // Work out how many lines have been allocated for this build output
+                    if (bom_items && row.allocations) {
                         n_bom_lines = bom_items.length;
+
+                        bom_items.forEach(function(bom_row) {
+                            var required_quantity = row.quantity * bom_row.quantity;
+
+                            if (sumAllocationsForBomRow(bom_row, row.allocations) >= required_quantity) {
+                                n_completed_lines += 1;
+                            }
+                        })
                     }
-                    return `lines: ${n_bom_lines}`;
-                    return `<div id='output-progress-${row.pk}'><span class='fas fa-spin fa-spinner'></span></div>`;
+
+                    var progressBar = makeProgressBar(
+                        n_completed_lines,
+                        n_bom_lines,
+                        {
+                            max_width: '150px',
+                        }
+                    );
+
+                    return `<div id='output-progress-${row.pk}'>${progressBar}</div>`;
                 },
                 sorter: function(value_a, value_b, row_a, row_b) {
                     // TODO: Custom sorter for "allocated stock" column
@@ -1108,7 +1182,7 @@ function loadBuildOutputTable(build_info, options={}) {
             },
             {
                 field: 'tests',
-                title: '{% trans "Tests" %}',
+                title: '{% trans "Completed Tests" %}',
                 sortable: true,
                 switchable: true,
                 formatter: function(value, row) {
@@ -1186,6 +1260,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: trackable,
+            },
+            {
+                async: false,
+                success: function(results) {
+                    bom_items = results;
+                }
+            }
+        );
+    }
+
     var table = options.table;
 
     if (options.table == null) {
@@ -1209,6 +1303,42 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
 
     var allocated_items = output == null ? null : output.allocations;
 
+    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
 
@@ -1225,32 +1355,18 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 success: function(response) {
                     allocated_items = response;
 
-                    if (async) {
+                    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;
-
-                        rows.forEach(function(row) {
-                            $(table).bootstrapTable('updateByUniqueId', row.pk, row, true);
-
-                            if (isRowFullyAllocated(row)) {
-                                allocated_rows += 1;
-                            }
-                        });
-                    }
                 }
             }
         );
     }
 
     if (allocated_items == null) {
-
         // No allocation data provided? Request from server (blocking)
         reloadAllocationData(false);
+    } else {
+        redrawAllocationData();
     }
 
     function reloadTable() {
@@ -1293,38 +1409,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         }
 
         return available;
-
     }
 
-    function getAllocationsForRow(row) {
-        var part_id = row.sub_part;
-
-        var allocations = [];
-
-        allocated_items.forEach(function(allocation) {
-            if (allocation.bom_part == part_id) {
-                allocations.push(allocation);
-            }
-        });
-
-        return allocations;
-    }
-
-    function sumAllocations(row) {
-        
-        var allocated_quantity = 0;
-
-        getAllocationsForRow(row).forEach(function(allocation) {
-            allocated_quantity += allocation.quantity;
-        });
-        
-        row.allocated = parseFloat(allocated_quantity.toFixed(15));
-
+    function allocatedQuantity(row) {
+        row.allocated = sumAllocationsForBomRow(row, allocated_items);
         return row.allocated;
     }
 
     function isRowFullyAllocated(row) {
-        return sumAllocations(row) >= requiredQuantity(row);
+        return allocatedQuantity(row) >= requiredQuantity(row);
     }
 
     function setupCallbacks() {
@@ -1386,7 +1479,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
             newBuildOrder({
                 part: pk,
                 parent: buildId,
-                quantity: requiredQuantity(row) - sumAllocations(row),
+                quantity: requiredQuantity(row) - allocatedQuantity(row),
             });
         });
 
@@ -1408,26 +1501,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         });
     }
 
-    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: trackable,
-            },
-            {
-                async: false,
-                success: function(results) {
-                    bom_items = results;
-                }
-            }
-        );
-    }
-
     // Load table of BOM items
     $(table).inventreeTable({
         data: bom_items,
@@ -1446,7 +1519,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         showColumns: false,
         detailView: true,
         detailFilter: function(index, row) {
-            return sumAllocations(row) > 0;
+            return allocatedQuantity(row) > 0;
         },
         detailFormatter: function(index, row, element) {
             // Contruct an 'inner table' which shows which stock items have been allocated
@@ -1460,7 +1533,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
             var subTable = $(`#${subTableId}`);
 
             subTable.bootstrapTable({
-                data: getAllocationsForRow(row),
+                data: getAllocationsForBomRow(row, allocated_items),
                 showHeader: true,
                 columns: [
                     {
@@ -1660,15 +1733,15 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 title: '{% trans "Allocated" %}',
                 sortable: true,
                 formatter: function(value, row) {
-                    var allocated = sumAllocations(row);
+                    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);
@@ -1703,7 +1776,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                     // Generate action buttons for this build output
                     var html = `<div class='btn-group float-right' role='group'>`;
 
-                    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" %}');
                         }
@@ -1719,7 +1792,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                         'fa-minus-circle icon-red', 'button-unallocate', row.sub_part,
                         '{% trans "Unallocate stock" %}',
                         {
-                            disabled: sumAllocations(row) == 0,
+                            disabled: allocatedQuantity(row) == 0,
                         }
                     );
 
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';

From 3da644637380a83e605a20cc420a7ffcb0f3dfc6 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 00:32:33 +1000
Subject: [PATCH 33/47] Allow sorting of build output table by allocated items

---
 InvenTree/templates/js/translated/build.js | 43 +++++++++++-----------
 1 file changed, 22 insertions(+), 21 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 2c0b56c518..c91dc1ccc0 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1060,6 +1060,21 @@ function loadBuildOutputTable(build_info, options={}) {
         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,
@@ -1146,28 +1161,12 @@ function loadBuildOutputTable(build_info, options={}) {
                 title: '{% trans "Allocated Stock" %}',
                 visible: true,
                 switchable: false,
+                sortable: true,
                 formatter: function(value, row) {
 
-                    // Display a progress bar which shows how many BOM lines have been fully allocated
-                    var n_bom_lines = 1;
-                    var n_completed_lines = 0;
-                    
-                    // Work out how many lines have been allocated for this build output
-                    if (bom_items && row.allocations) {
-                        n_bom_lines = bom_items.length;
-
-                        bom_items.forEach(function(bom_row) {
-                            var required_quantity = row.quantity * bom_row.quantity;
-
-                            if (sumAllocationsForBomRow(bom_row, row.allocations) >= required_quantity) {
-                                n_completed_lines += 1;
-                            }
-                        })
-                    }
-
                     var progressBar = makeProgressBar(
-                        n_completed_lines,
-                        n_bom_lines,
+                        countAllocatedLines(row),
+                        bom_items.length,
                         {
                             max_width: '150px',
                         }
@@ -1176,8 +1175,10 @@ function loadBuildOutputTable(build_info, options={}) {
                     return `<div id='output-progress-${row.pk}'>${progressBar}</div>`;
                 },
                 sorter: function(value_a, value_b, row_a, row_b) {
-                    // TODO: Custom sorter for "allocated stock" column
-                    return 0;
+                    var q_a = countAllocatedLines(row_a);
+                    var q_b = countAllocatedLines(row_b);
+
+                    return q_a > q_b ? 1 : -1;
                 },
             },
             {

From 6e52ca21785c146ec6c1a0df83843b14de0b6090 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 00:44:29 +1000
Subject: [PATCH 34/47] Refactor button callbacks

- Add button to expand all output rows
- Add button to collapse all output rows
---
 InvenTree/build/templates/build/detail.html | 72 +++-----------------
 InvenTree/templates/js/translated/build.js  | 73 +++++++++++++++++++++
 2 files changed, 83 insertions(+), 62 deletions(-)

diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index c01c9055f1..52b81e6389 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -270,6 +270,16 @@
                         </ul>
                     </div>
 
+                    {% if build.has_tracked_bom_items %}
+                    <button id='outputs-expand' class='btn btn-outline-secondary' type='button' title='{% trans "Expand all build output rows" %}'>
+                        <span class='fas fa-expand'></span>
+                    </button>
+
+                    <button id='outputs-collapse' class='btn btn-outline-secondary' type='button' title='{% trans "Collapse all build output rows" %}'>
+                        <span class='fas fa-compress'></span>
+                    </button>
+                    {% endif %}
+
                     {% include "filter_list.html" with id='incompletebuilditems' %}
                 </div>
                 {% endif %}
@@ -416,68 +426,6 @@ onPanelLoad('outputs', function() {
 
     loadBuildOutputTable(build_info);
 
-    linkButtonsToSelection(
-        '#build-output-table',
-        [
-            '#output-options',
-            '#multi-output-complete',
-            '#multi-output-delete',
-        ]
-    );
-
-    $('#multi-output-complete').click(function() {
-        var outputs = $('#build-output-table').bootstrapTable('getSelections');
-
-        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');
-                }
-            }
-        );
-    });
-
-    $('#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 %}
 });
 
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index c91dc1ccc0..935206ce2f 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1233,6 +1233,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');
+    });
 }
 
 

From 81638d06cf06901bf879c5d336ec4449b9e60443 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 00:51:56 +1000
Subject: [PATCH 35/47] Show or hide items based on output BOM

---
 InvenTree/templates/js/translated/build.js | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 935206ce2f..705060ce63 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 = `<div class='btn-group float-right' role='group'>`;
 
     // 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(
@@ -1085,9 +1085,9 @@ function loadBuildOutputTable(build_info, options={}) {
         sortable: true,
         search: false,
         sidePagination: 'client',
-        detailView: true,
+        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);
@@ -1159,11 +1159,15 @@ function loadBuildOutputTable(build_info, options={}) {
             {
                 field: 'allocated',
                 title: '{% trans "Allocated Stock" %}',
-                visible: true,
+                visible: bom_items.length > 0,
                 switchable: false,
                 sortable: true,
                 formatter: function(value, row) {
 
+                    if (bom_items.length == 0) {
+                        return `<div id='output-progress-${row.pk}'><em><small>{% trans "No tracked BOM items for this build" %}</small></em></div>`;
+                    }
+
                     var progressBar = makeProgressBar(
                         countAllocatedLines(row),
                         bom_items.length,
@@ -1188,7 +1192,7 @@ function loadBuildOutputTable(build_info, options={}) {
                 switchable: true,
                 formatter: function(value, row) {
                     if (part_tests == null || part_tests.length == 0) {
-                        return `<em>{% trans "No tests found " %}</em>`;
+                        return `<em><small>{% trans "No required tests for this build" %}</small></em>`;
                     }
 
                     var n_passed = countPassedTests(row);
@@ -1218,6 +1222,9 @@ function loadBuildOutputTable(build_info, options={}) {
                     return makeBuildOutputButtons(
                         row.pk,
                         build_info,
+                        {
+                            has_bom_items: bom_items.length > 0,
+                        }
                     );
                 }
             }

From 6b4592b3dcc2349f00a4125ff4cf9a1c6e13d628 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:10:05 +1000
Subject: [PATCH 36/47] Display error if stock item is "double allocted"

---
 InvenTree/build/serializers.py             | 9 +++++++--
 InvenTree/templates/js/translated/build.js | 4 ++--
 2 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index 7df2c474a9..d037ad546e 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -630,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']
@@ -654,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/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 705060ce63..c996501060 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1982,7 +1982,7 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
         // var stock_input = constructRelatedFieldInput(`items_stock_item_${pk}`);
 
         var html = `
-        <tr id='allocation_row_${pk}' class='part-allocation-row'>
+        <tr id='items_${pk}' class='part-allocation-row'>
             <td id='part_${pk}'>
                 ${thumb} ${sub_part.full_name}
             </td>
@@ -2167,7 +2167,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) {

From e0189be5a6ec0e92ba8ee67fa63d2a5f0cd1fbea Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:19:36 +1000
Subject: [PATCH 37/47] Adds ability to filter StockItemTestresult API list by
 Build ID

- Allows us to retrieve stock item test results in a single API query
---
 InvenTree/stock/api.py | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index d4fc5c93d1..f6b21ca5af 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,20 @@ 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)
 

From a0ca20ab04e71d978b2519dcd557af78962420fb Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:27:58 +1000
Subject: [PATCH 38/47] Retrieve all stock item test results at once

---
 InvenTree/templates/js/translated/build.js | 45 ++++++++++------------
 1 file changed, 21 insertions(+), 24 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index c996501060..5fe9165c9d 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1007,39 +1007,36 @@ function loadBuildOutputTable(build_info, options={}) {
             return;
         }
 
-        rows.forEach(function(row) {
+        // Retrieve stock results for the entire build
+        inventreeGet(
+            '{% url "api-stock-test-result-list" %}',
+            {
+                build: build_info.pk,
+            },
+            {
+                success: function(results) {
 
-            // Ignore if this row has already been updated (else, infinite loop!)
-            if (row.passed_tests) {
-                return;
-            }
+                    // Iterate through each row and find matching test results
+                    rows.forEach(function(row) {
+                        var test_results = {};
 
-            // Request test result information for the particular build output
-            inventreeGet(
-                '{% url "api-stock-test-result-list" %}',
-                {
-                    stock_item: row.pk,
-                },
-                {
-                    success: function(results) {
-
-                        // A list of tests that this stock item has passed
-                        var passed_tests = {};
-
-                        // Keep a list of tests that this stock item has passed
                         results.forEach(function(result) {
-                            if (result.result) {
-                                passed_tests[result.key] = true;
+                            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 = passed_tests;
+                        row.passed_tests = test_results;
 
                         $(table).bootstrapTable('updateByUniqueId', row.pk, row, true);
-                    }
+                    });
                 }
-            )
-        });
+            }
+        );
     }
 
     // Return the number of 'passed' tests in a given row

From 0bda9c974e1389a8ef08bdcb1fab9db1fab5e9fa Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:35:09 +1000
Subject: [PATCH 39/47] PEP fixes

---
 InvenTree/build/models.py | 2 +-
 InvenTree/stock/api.py    | 3 +--
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 61783fc8fb..86bb256539 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -1292,7 +1292,7 @@ class BuildItem(models.Model):
                 user,
                 notes
             )
-            
+
         else:
             # Simply remove the items from stock
             item.take_stock(
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index f6b21ca5af..c88c29e64e 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -1165,7 +1165,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
         build = params.get('build', None)
 
         if build is not None:
-            
+
             try:
                 build = Build.objects.get(pk=build)
 
@@ -1174,7 +1174,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
             except (ValueError, Build.DoesNotExist):
                 pass
 
-
         # Filter by stock item
         item = params.get('stock_item', None)
 

From b595fa0e7eb47d734d3d2c35cffb65e18e72d425 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:40:59 +1000
Subject: [PATCH 40/47] Fix loading of untracked parts table

---
 InvenTree/templates/js/translated/build.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 5fe9165c9d..502f95d65f 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1347,7 +1347,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
             {
                 part: partId,
                 sub_part_detail: true,
-                sub_part_trackable: trackable,
+                sub_part_trackable: buildInfo.tracked_parts,
             },
             {
                 async: false,

From 51da1f02a8d126e32fa84da7f1057ad52f13afb4 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 01:58:57 +1000
Subject: [PATCH 41/47] JS linting fixes

---
 InvenTree/templates/js/translated/build.js | 21 ++++++---------------
 1 file changed, 6 insertions(+), 15 deletions(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 502f95d65f..a88f3ccb28 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -867,7 +867,7 @@ 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
+    // Request list of BOM data for this build order
     inventreeGet(
         '{% url "api-bom-list" %}',
         {
@@ -962,7 +962,7 @@ function loadBuildOutputTable(build_info, options={}) {
 
                             var output_progress_bar = $(`#output-progress-${row.pk}`);
 
-                            if  (output_progress_bar.exists()) {
+                            if (output_progress_bar.exists()) {
                                 output_progress_bar.html(
                                     makeProgressBar(
                                         n_completed_lines,
@@ -977,7 +977,7 @@ function loadBuildOutputTable(build_info, options={}) {
                     });
                 }
             }
-        )
+        );
     }
 
     var part_tests = null;
@@ -1283,14 +1283,14 @@ function loadBuildOutputTable(build_info, options={}) {
                     $('#build-stock-table').bootstrapTable('refresh');
                 }
             }
-        )
+        );
     });
 
     // Print stock item labels
     $('#incomplete-output-print-label').click(function() {
         var outputs = $(table).bootstrapTable('getSelections');
 
-        if  (outputs.length == 0) {
+        if (outputs.length == 0) {
             outputs = $(table).bootstrapTable('getData');
         }
 
@@ -1375,10 +1375,6 @@ 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 redrawAllocationData() {
@@ -1447,11 +1443,6 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
         redrawAllocationData();
     }
 
-    function reloadTable() {
-        // Reload the entire build allocation table
-        $(table).bootstrapTable('refresh');
-    }
-
     function requiredQuantity(row) {
         // Return the requied quantity for a given row
 
@@ -1812,7 +1803,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
                 sortable: true,
                 formatter: function(value, row) {
                     var allocated = allocatedQuantity(row);
-                    var required = requiredQuantity(row)
+                    var required = requiredQuantity(row);
                     return makeProgressBar(allocated, required);
                 },
                 sorter: function(valA, valB, rowA, rowB) {

From 8fc34a21a6b7be239c00743ce3e14a91ecd52669 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 12:59:20 +1000
Subject: [PATCH 42/47] Reload the untracked stock table when allocation
 actions are performed

---
 InvenTree/build/templates/build/detail.html | 52 ++++++++++++---------
 InvenTree/templates/js/translated/build.js  |  4 +-
 2 files changed, 32 insertions(+), 24 deletions(-)

diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index 52b81e6389..42bc51bb2f 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -431,24 +431,32 @@ onPanelLoad('outputs', function() {
 
 {% if build.active and build.has_untracked_bom_items %}
 
-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,
-};
+function loadUntrackedStockTable() {
+
+    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');
+
+    // Load allocation table for un-tracked parts
+    loadBuildOutputAllocationTable(
+        build_info,
+        null,
+        {
+            search: true,
+        }
+    );
+}
+
+loadUntrackedStockTable();
 
-// Load allocation table for un-tracked parts
-loadBuildOutputAllocationTable(
-    build_info,
-    null,
-    {
-        search: true,
-    }
-);
 {% endif %}
 
 $('#btn-create-output').click(function() {
@@ -472,6 +480,7 @@ $("#btn-auto-allocate").on('click', function() {
             {% if build.take_from %}
             location: {{ build.take_from.pk }},
             {% endif %}
+            onSuccess: loadUntrackedStockTable,
         }
     );
 });
@@ -503,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,
             }
         );
     }
@@ -514,6 +521,7 @@ $("#btn-allocate").on('click', function() {
 $('#btn-unallocate').on('click', function() {
     unallocateStock({{ build.id }}, {
         table: '#allocation-table-untracked',
+        onSuccess: loadUntrackedStockTable,
     });
 });
 
@@ -533,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/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index a88f3ccb28..a8a973d516 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -2270,7 +2270,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);
+            }
         }
     });
 }

From a80465e85cbdab6093479c97c18a7bb04a547d0c Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 13:03:41 +1000
Subject: [PATCH 43/47] Display batch code in build output table

---
 InvenTree/templates/js/translated/build.js | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index a8a973d516..719df6ba5c 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1135,6 +1135,10 @@ function loadBuildOutputTable(build_info, options={}) {
                         text = `{% trans "Quantity" %}: ${row.quantity}`;
                     }
 
+                    if (row.batch) {
+                        text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
+                    }
+
                     return renderLink(text, url);
                 },
                 sorter: function(a, b, row_a, row_b) {

From 94fa424440b033fc306c6bbc1db64fb0606b7ff6 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 13:13:12 +1000
Subject: [PATCH 44/47] Table tweaks

---
 InvenTree/build/api.py                     |  1 +
 InvenTree/templates/js/translated/build.js | 10 +++++-----
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 22dd6473ab..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',
     ]
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 719df6ba5c..c9ebbe0e22 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1121,7 +1121,7 @@ function loadBuildOutputTable(build_info, options={}) {
             {
                 field: 'quantity',
                 title: '{% trans "Build Output" %}',
-                switchable: true,
+                switchable: false,
                 sortable: true,
                 formatter: function(value, row) {
 
@@ -1834,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;
                 }
             },
             {
@@ -2374,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(

From 1bb2551edb2afff7543c86cdc0566bd9c5fb8521 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 13:51:49 +1000
Subject: [PATCH 45/47] Fixes for model rendering code

---
 .../js/translated/model_renderers.js          | 26 +++++++------------
 1 file changed, 9 insertions(+), 17 deletions(-)

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 = `
     <span>
         ${part_detail}
@@ -146,7 +144,7 @@ function renderStockLocation(name, data, parameters={}, options={}) {
         html += ` - <i>${data.description}</i>`;
     }
 
-    html += `<span class='float-right'><small>{% trans "Location ID" %}: ${data.pk}</small></span>`;
+    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 += `<span><b>${data.reference}</b></span> - ${data.quantity} x ${data.part_detail.full_name}`;
-    html += `<span class='float-right'><small>{% trans "Build ID" %}: ${data.pk}</span></span>`;
+    html += `<span><b>${data.reference}</b> - ${data.quantity} x ${data.part_detail.full_name}</span>`;
 
-    html += `<p><i>${data.title}</i></p>`;
+    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 = `
-    <span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
-    <span class='float-right'>
-        <small>{% trans "Shipment ID" %}: ${data.pk}</small>
-    </span>
-    `;
+    var html = `<span>${so_prefix}${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>`;
+
+    html += renderId('{% trans "Shipment ID" %}', data.pk, parameters);
 
     return html;
 }
@@ -323,7 +317,7 @@ function renderPartCategory(name, data, parameters={}, options={}) {
         html += ` - <i>${data.description}</i>`;
     }
 
-    html += `<span class='float-right'><small>{% trans "Category ID" %}: ${data.pk}</small></span>`;
+    html += renderId('{% trans "Category ID" %}', data.pk, parameters);
 
     return html;
 }
@@ -366,7 +360,7 @@ function renderManufacturerPart(name, data, parameters={}, options={}) {
     html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
     html += ` - <i>${data.part_detail.full_name}</i>`;
 
-    html += `<span class='float-right'><small>{% trans "Manufacturer Part ID" %}: ${data.pk}</small></span>`;
+    html += renderId('{% trans "Manufacturer Part ID" %}', data.pk, parameters);
 
     return html;
 }
@@ -395,9 +389,7 @@ function renderSupplierPart(name, data, parameters={}, options={}) {
     html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
     html += ` - <i>${data.part_detail.full_name}</i>`;
 
-    html += `<span class='float-right'><small>{% trans "Supplier Part ID" %}: ${data.pk}</small></span>`;
-
+    html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters);
 
     return html;
-
 }

From 260680c5c494156dcb628618e461964f83e04580 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 17:07:54 +1000
Subject: [PATCH 46/47] Refactor BOM table to not load multi level BOMs by
 default

- Click to select which ones to load
---
 InvenTree/templates/js/translated/bom.js | 86 +++++++++++-------------
 1 file changed, 39 insertions(+), 47 deletions(-)

diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 2d7796edcd..c3180bf490 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 += `<a href='#'><span class='fas fa-sync fa-spin'></span></a>`;
+                    } else {
+                        html += `
+                            <a href='#' pk='${row.pk}' class='load-sub-assembly' id='load-sub-assembly-${row.pk}'>
+                                <span class='fas fa-sync-alt' title='{% trans "Load BOM for subassembly" %}'></span>
+                            </a> `;
+                    }
+                }
+
+                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 = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream float-right'></span>`;
-
-                    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);
-                }
-            }
         },
     });
 

From 35de490f72b75b28ffdfee3a39295f145e87adf5 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 29 Apr 2022 17:13:59 +1000
Subject: [PATCH 47/47] JS linting fixes

---
 InvenTree/templates/js/translated/bom.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index c3180bf490..e4674d5989 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -1116,8 +1116,8 @@ function loadBomTable(table, options={}) {
 
             table.treegrid('collapseAll');
 
-             // Callback for 'load sub assembly' button
-             $(table).find('.load-sub-assembly').click(function(event) {
+            // Callback for 'load sub assembly' button
+            $(table).find('.load-sub-assembly').click(function(event) {
 
                 event.preventDefault();