From 1088b9c947d404b9b8c44eee748ba2bbdacde34d Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 4 Apr 2023 11:42:49 +1000 Subject: [PATCH] Allow barcodes to be added to incoming items via web UI (#4574) * Fixes for receiving a line item with a barcode - Record the raw barcode data in addition to the hash * Improvements to 'receive purchase order items' dialog * Add code for assigning barcodes to incoming order items * Unit test fixes --- InvenTree/InvenTree/models.py | 5 +- InvenTree/InvenTree/static/css/inventree.css | 6 + InvenTree/order/models.py | 16 +- InvenTree/order/serializers.py | 11 +- InvenTree/order/test_api.py | 7 +- InvenTree/templates/js/translated/barcode.js | 67 +++++---- InvenTree/templates/js/translated/helpers.js | 10 +- .../templates/js/translated/purchase_order.js | 138 +++++++++++++----- 8 files changed, 181 insertions(+), 79 deletions(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 61adfe8e4b..41ade9f1c6 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -731,7 +731,7 @@ class InvenTreeBarcodeMixin(models.Model): return cls.objects.filter(barcode_hash=barcode_hash).first() - def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True): + def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True, save=True): """Assign an external (third-party) barcode to this object.""" # Must provide either barcode_hash or barcode_data @@ -754,7 +754,8 @@ class InvenTreeBarcodeMixin(models.Model): self.barcode_hash = barcode_hash - self.save() + if save: + self.save() return True diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index cd9b5747d3..c5bc9ea58e 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -784,6 +784,12 @@ input[type="submit"] { .alert-block { display: block; + padding: 0.75rem; +} + +.alert-small { + padding: 0.35rem; + font-size: 75%; } .navbar .btn { diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 3ec7c82b72..617aa92678 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -549,11 +549,11 @@ class PurchaseOrder(TotalPriceMixin, Order): notes = kwargs.get('notes', '') # Extract optional barcode field - barcode_hash = kwargs.get('barcode', None) + barcode = kwargs.get('barcode', None) # Prevent null values for barcode - if barcode_hash is None: - barcode_hash = '' + if barcode is None: + barcode = '' if self.status != PurchaseOrderStatus.PLACED: raise ValidationError( @@ -600,10 +600,16 @@ class PurchaseOrder(TotalPriceMixin, Order): status=status, batch=batch_code, serial=sn, - purchase_price=unit_purchase_price, - barcode_hash=barcode_hash + purchase_price=unit_purchase_price ) + # Assign the provided barcode + if barcode: + item.assign_barcode( + barcode_data=barcode, + save=False + ) + item.save(add_note=False) tracking_info = { diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 9f188c2fd9..9624394943 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -19,7 +19,8 @@ import stock.models import stock.serializers from company.serializers import (CompanyBriefSerializer, ContactSerializer, SupplierPartSerializer) -from InvenTree.helpers import extract_serial_numbers, normalize, str2bool +from InvenTree.helpers import (extract_serial_numbers, hash_barcode, normalize, + str2bool) from InvenTree.serializers import (InvenTreeAttachmentSerializer, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -505,8 +506,8 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) barcode = serializers.CharField( - label=_('Barcode Hash'), - help_text=_('Unique identifier field'), + label=_('Barcode'), + help_text=_('Scanned barcode'), default='', required=False, allow_null=True, @@ -519,7 +520,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): if not barcode or barcode.strip() == '': return None - if stock.models.StockItem.objects.filter(barcode_hash=barcode).exists(): + barcode_hash = hash_barcode(barcode) + + if stock.models.StockItem.lookup_barcode(barcode_hash) is not None: raise ValidationError(_('Barcode is already in use')) return barcode diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 98bfe8f840..7b990c9639 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -837,8 +837,7 @@ class PurchaseOrderReceiveTest(OrderTest): """ # Set stock item barcode item = StockItem.objects.get(pk=1) - item.barcode_hash = 'MY-BARCODE-HASH' - item.save() + item.assign_barcode(barcode_data='MY-BARCODE-HASH') response = self.post( self.url, @@ -956,8 +955,8 @@ class PurchaseOrderReceiveTest(OrderTest): self.assertEqual(stock_2.last().location.pk, 2) # Barcodes should have been assigned to the stock items - self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-123').exists()) - self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-456').exists()) + self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()) + self.assertTrue(StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-456').exists()) def test_batch_code(self): """Test that we can supply a 'batch code' when receiving items.""" diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index c8213e41da..71b1d26376 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -146,7 +146,7 @@ function makeNotesField(options={}) { */ function postBarcodeData(barcode_data, options={}) { - var modal = options.modal || '#modal-form'; + var modal = options.modal; var url = options.url || '{% url "api-barcode-scan" %}'; @@ -166,11 +166,14 @@ function postBarcodeData(barcode_data, options={}) { switch (xhr.status || 0) { case 400: // No match for barcode, most likely - console.log(xhr); - - data = xhr.responseJSON || {}; - showBarcodeMessage(modal, data.error || '{% trans "Server error" %}'); + if (options.onError400) { + options.onError400(xhr.responseJSON, options); + } else { + console.log(xhr); + data = xhr.responseJSON || {}; + showBarcodeMessage(modal, data.error || '{% trans "Server error" %}'); + } break; default: // Any other error code means something went wrong @@ -187,7 +190,7 @@ function postBarcodeData(barcode_data, options={}) { if ('success' in response) { if (options.onScan) { - options.onScan(response); + options.onScan(response, options); } } else if ('error' in response) { showBarcodeMessage( @@ -258,7 +261,7 @@ function enableBarcodeInput(modal, enabled=true) { */ function getBarcodeData(modal) { - modal = modal || '#modal-form'; + modal = modal || createNewModal(); var el = $(modal + ' #barcode'); @@ -276,7 +279,9 @@ function getBarcodeData(modal) { */ function barcodeDialog(title, options={}) { - var modal = '#modal-form'; + var modal = createNewModal(); + + options.modal = modal; function sendBarcode() { var barcode = getBarcodeData(modal); @@ -396,26 +401,33 @@ function barcodeDialog(title, options={}) { * Perform a barcode scan, * and (potentially) redirect the browser */ -function barcodeScanDialog() { +function barcodeScanDialog(options={}) { - var modal = '#modal-form'; + let modal = options.modal || createNewModal(); + let title = options.title || '{% trans "Scan Barcode" %}'; barcodeDialog( - '{% trans "Scan Barcode" %}', + title, { onScan: function(response) { - var url = response.url; - - if (url) { - $(modal).modal('hide'); - window.location.href = url; + // Pass the response to the calling function + if (options.onScan) { + options.onScan(response); } else { - showBarcodeMessage( - modal, - '{% trans "No URL in response" %}', - 'warning' - ); + + let url = response.url; + + if (url) { + $(modal).modal('hide'); + window.location.href = url; + } else { + showBarcodeMessage( + modal, + '{% trans "No URL in response" %}', + 'warning' + ); + } } } }, @@ -428,7 +440,8 @@ function barcodeScanDialog() { */ function linkBarcodeDialog(data, options={}) { - var modal = '#modal-form'; + var modal = options.modal || createNewModal(); + options.modal = modal; barcodeDialog( options.title, @@ -481,7 +494,8 @@ function unlinkBarcode(data, options={}) { */ function barcodeCheckInStockItems(location_id, options={}) { - var modal = '#modal-form'; + var modal = options.modal || createNewModal(); + options.modal = modal; // List of items we are going to checkin var items = []; @@ -672,7 +686,9 @@ function barcodeCheckInStockItems(location_id, options={}) { */ function barcodeCheckInStockLocations(location_id, options={}) { - var modal = '#modal-form'; + var modal = options.modal || createNewModal(); + options.modal = modal; + var header = ''; barcodeDialog( @@ -725,7 +741,8 @@ function barcodeCheckInStockLocations(location_id, options={}) { */ function scanItemsIntoLocation(item_list, options={}) { - var modal = options.modal || '#modal-form'; + var modal = options.modal || createNewModal(); + options.modal = modal; var stock_location = null; diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index e400e33707..04b50d5cbd 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -210,7 +210,13 @@ function makeIconButton(icon, cls, pk, title, options={}) { var html = ''; - var extraProps = ''; + var extraProps = options.extra || ''; + + var style = ''; + + if (options.hidden) { + style += `display: none;`; + } if (options.disabled) { extraProps += `disabled='true' `; @@ -220,7 +226,7 @@ function makeIconButton(icon, cls, pk, title, options={}) { extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`; } - html += ``; diff --git a/InvenTree/templates/js/translated/purchase_order.js b/InvenTree/templates/js/translated/purchase_order.js index 660c8491a6..77156d9a3e 100644 --- a/InvenTree/templates/js/translated/purchase_order.js +++ b/InvenTree/templates/js/translated/purchase_order.js @@ -1010,19 +1010,6 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { quantity = 0; } - // Prepend toggles to the quantity input - var toggle_batch = ` - - - - `; - - var toggle_serials = ` - - - - `; - var units = line_item.part_detail.units || ''; var pack_size = line_item.supplier_part_detail.pack_size || 1; var pack_size_div = ''; @@ -1031,7 +1018,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { if (pack_size != 1) { pack_size_div = ` -
+
{% trans "Pack Quantity" %}: ${pack_size} ${units}
{% trans "Received Quantity" %}: ${received} ${units}
`; @@ -1060,7 +1047,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { required: false, label: '{% trans "Batch Code" %}', help_text: '{% trans "Enter batch code for incoming stock items" %}', - prefixRaw: toggle_batch, + icon: 'fa-layer-group', + }, + { + hideLabels: true, + } + ); + + // Hidden barcode input + var barcode_input = constructField( + `items_barcode_${pk}`, + { + type: 'string', + required: 'false', + hidden: 'true' } ); @@ -1071,16 +1071,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { required: false, label: '{% trans "Serial Numbers" %}', help_text: '{% trans "Enter serial numbers for incoming stock items" %}', - prefixRaw: toggle_serials, + icon: 'fa-hashtag', + }, + { + hideLabels: true, } ); - // Hidden inputs below the "quantity" field - var quantity_input_group = `${quantity_input}${pack_size_div}
${batch_input}
`; - - if (line_item.part_detail.trackable) { - quantity_input_group += `
${sn_input}
`; - } + var quantity_input_group = `${quantity_input}${pack_size_div}`; // Construct list of StockItem status codes var choices = []; @@ -1098,6 +1096,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { type: 'related field', label: '{% trans "Location" %}', required: false, + icon: 'fa-sitemap', }, { hideLabels: true, @@ -1121,13 +1120,22 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { // Button to remove the row let buttons = ''; + if (global_settings.BARCODE_ENABLE) { + buttons += makeIconButton('fa-qrcode', 'button-row-add-barcode', pk, '{% trans "Add barcode" %}'); + buttons += makeIconButton('fa-unlink icon-red', 'button-row-remove-barcode', pk, '{% trans "Remove barcode" %}', {hidden: true}); + } + + buttons += makeIconButton('fa-sitemap', 'button-row-add-location', pk, '{% trans "Specify location" %}', { + collapseTarget: `row-destination-${pk}` + }); + buttons += makeIconButton( 'fa-layer-group', 'button-row-add-batch', pk, '{% trans "Add batch code" %}', { - collapseTarget: `div-batch-${pk}` + collapseTarget: `row-batch-${pk}` } ); @@ -1138,7 +1146,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { pk, '{% trans "Add serial numbers" %}', { - collapseTarget: `div-serials-${pk}`, + collapseTarget: `row-serials-${pk}`, } ); } @@ -1149,6 +1157,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { buttons = wrapButtons(buttons); + let progress = makeProgressBar(line_item.received, line_item.quantity); + var html = ` @@ -1157,11 +1167,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${line_item.supplier_part_detail.SKU} - - ${line_item.quantity} - - ${line_item.received} + ${progress} ${quantity_input_group} @@ -1169,13 +1176,31 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { ${status_input} - - ${destination_input} - + ${barcode_input} ${buttons} - `; + + + + + {% trans "Location" %} + ${destination_input} + + + + + {% trans "Batch" %} + ${batch_input} + + + + + {% trans "Serials" %} + ${sn_input} + + + `; return html; } @@ -1192,16 +1217,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { // Add table html += ` - +
- - @@ -1284,6 +1307,44 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { } }); + // Add callbacks to add barcode + if (global_settings.BARCODE_ENABLE) { + $(opts.modal).find('.button-row-add-barcode').click(function() { + var btn = $(this); + let pk = btn.attr('pk'); + + // Scan to see if the barcode matches an existing StockItem + barcodeDialog('{% trans "Scan Item Barcode" %}', { + details: '{% trans "Scan barcode on incoming item (must not match any existing stock items)" %}', + onScan: function(response, barcode_options) { + // A 'success' result means that the barcode matches something existing in the database + showBarcodeMessage(barcode_options.modal, '{% trans "Barcode matches existing item" %}'); + }, + onError400: function(response, barcode_options) { + if (response.barcode_data && response.barcode_hash) { + // Success! Hide the modal and update the value + $(barcode_options.modal).modal('hide'); + + btn.hide(); + $(opts.modal).find(`#button-row-remove-barcode-${pk}`).show(); + updateFieldValue(`items_barcode_${pk}`, response.barcode_data, {}, opts); + } else { + showBarcodeMessage(barcode_options.modal, '{% trans "Invalid barcode data" %}'); + } + } + }); + }); + + $(opts.modal).find('.button-row-remove-barcode').click(function() { + var btn = $(this); + let pk = btn.attr('pk'); + + btn.hide(); + $(opts.modal).find(`#button-row-add-barcode-${pk}`).show(); + updateFieldValue(`items_barcode_${pk}`, '', {}, opts); + }); + } + // Add callbacks to remove rows $(opts.modal).find('.button-row-remove').click(function() { var pk = $(this).attr('pk'); @@ -1304,10 +1365,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { var pk = item.pk; + // Extract data for each line var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); - var status = getFormFieldValue(`items_status_${pk}`, {}, opts); - var location = getFormFieldValue(`items_location_${pk}`, {}, opts); if (quantity != null) { @@ -1319,6 +1379,10 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) { location: location, }; + if (global_settings.BARCODE_ENABLE) { + line.barcode = getFormFieldValue(`items_barcode_${pk}`, {}, opts); + } + if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); }
{% trans "Part" %} {% trans "Order Code" %}{% trans "Ordered" %} {% trans "Received" %} {% trans "Quantity to Receive" %} {% trans "Status" %}{% trans "Destination" %}