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
This commit is contained in:
Oliver 2023-04-04 11:42:49 +10:00 committed by GitHub
parent 7af2bb4e8c
commit 1088b9c947
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 181 additions and 79 deletions

View File

@ -731,7 +731,7 @@ class InvenTreeBarcodeMixin(models.Model):
return cls.objects.filter(barcode_hash=barcode_hash).first() 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.""" """Assign an external (third-party) barcode to this object."""
# Must provide either barcode_hash or barcode_data # Must provide either barcode_hash or barcode_data
@ -754,6 +754,7 @@ class InvenTreeBarcodeMixin(models.Model):
self.barcode_hash = barcode_hash self.barcode_hash = barcode_hash
if save:
self.save() self.save()
return True return True

View File

@ -784,6 +784,12 @@ input[type="submit"] {
.alert-block { .alert-block {
display: block; display: block;
padding: 0.75rem;
}
.alert-small {
padding: 0.35rem;
font-size: 75%;
} }
.navbar .btn { .navbar .btn {

View File

@ -549,11 +549,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
notes = kwargs.get('notes', '') notes = kwargs.get('notes', '')
# Extract optional barcode field # Extract optional barcode field
barcode_hash = kwargs.get('barcode', None) barcode = kwargs.get('barcode', None)
# Prevent null values for barcode # Prevent null values for barcode
if barcode_hash is None: if barcode is None:
barcode_hash = '' barcode = ''
if self.status != PurchaseOrderStatus.PLACED: if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError( raise ValidationError(
@ -600,8 +600,14 @@ class PurchaseOrder(TotalPriceMixin, Order):
status=status, status=status,
batch=batch_code, batch=batch_code,
serial=sn, serial=sn,
purchase_price=unit_purchase_price, purchase_price=unit_purchase_price
barcode_hash=barcode_hash )
# Assign the provided barcode
if barcode:
item.assign_barcode(
barcode_data=barcode,
save=False
) )
item.save(add_note=False) item.save(add_note=False)

View File

@ -19,7 +19,8 @@ import stock.models
import stock.serializers import stock.serializers
from company.serializers import (CompanyBriefSerializer, ContactSerializer, from company.serializers import (CompanyBriefSerializer, ContactSerializer,
SupplierPartSerializer) 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, from InvenTree.serializers import (InvenTreeAttachmentSerializer,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
@ -505,8 +506,8 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
) )
barcode = serializers.CharField( barcode = serializers.CharField(
label=_('Barcode Hash'), label=_('Barcode'),
help_text=_('Unique identifier field'), help_text=_('Scanned barcode'),
default='', default='',
required=False, required=False,
allow_null=True, allow_null=True,
@ -519,7 +520,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
if not barcode or barcode.strip() == '': if not barcode or barcode.strip() == '':
return None 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')) raise ValidationError(_('Barcode is already in use'))
return barcode return barcode

View File

@ -837,8 +837,7 @@ class PurchaseOrderReceiveTest(OrderTest):
""" """
# Set stock item barcode # Set stock item barcode
item = StockItem.objects.get(pk=1) item = StockItem.objects.get(pk=1)
item.barcode_hash = 'MY-BARCODE-HASH' item.assign_barcode(barcode_data='MY-BARCODE-HASH')
item.save()
response = self.post( response = self.post(
self.url, self.url,
@ -956,8 +955,8 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_2.last().location.pk, 2) self.assertEqual(stock_2.last().location.pk, 2)
# Barcodes should have been assigned to the stock items # 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_data='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-456').exists())
def test_batch_code(self): def test_batch_code(self):
"""Test that we can supply a 'batch code' when receiving items.""" """Test that we can supply a 'batch code' when receiving items."""

View File

@ -146,7 +146,7 @@ function makeNotesField(options={}) {
*/ */
function postBarcodeData(barcode_data, options={}) { function postBarcodeData(barcode_data, options={}) {
var modal = options.modal || '#modal-form'; var modal = options.modal;
var url = options.url || '{% url "api-barcode-scan" %}'; var url = options.url || '{% url "api-barcode-scan" %}';
@ -166,11 +166,14 @@ function postBarcodeData(barcode_data, options={}) {
switch (xhr.status || 0) { switch (xhr.status || 0) {
case 400: case 400:
// No match for barcode, most likely // No match for barcode, most likely
console.log(xhr);
if (options.onError400) {
options.onError400(xhr.responseJSON, options);
} else {
console.log(xhr);
data = xhr.responseJSON || {}; data = xhr.responseJSON || {};
showBarcodeMessage(modal, data.error || '{% trans "Server error" %}'); showBarcodeMessage(modal, data.error || '{% trans "Server error" %}');
}
break; break;
default: default:
// Any other error code means something went wrong // Any other error code means something went wrong
@ -187,7 +190,7 @@ function postBarcodeData(barcode_data, options={}) {
if ('success' in response) { if ('success' in response) {
if (options.onScan) { if (options.onScan) {
options.onScan(response); options.onScan(response, options);
} }
} else if ('error' in response) { } else if ('error' in response) {
showBarcodeMessage( showBarcodeMessage(
@ -258,7 +261,7 @@ function enableBarcodeInput(modal, enabled=true) {
*/ */
function getBarcodeData(modal) { function getBarcodeData(modal) {
modal = modal || '#modal-form'; modal = modal || createNewModal();
var el = $(modal + ' #barcode'); var el = $(modal + ' #barcode');
@ -276,7 +279,9 @@ function getBarcodeData(modal) {
*/ */
function barcodeDialog(title, options={}) { function barcodeDialog(title, options={}) {
var modal = '#modal-form'; var modal = createNewModal();
options.modal = modal;
function sendBarcode() { function sendBarcode() {
var barcode = getBarcodeData(modal); var barcode = getBarcodeData(modal);
@ -396,16 +401,22 @@ function barcodeDialog(title, options={}) {
* Perform a barcode scan, * Perform a barcode scan,
* and (potentially) redirect the browser * 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( barcodeDialog(
'{% trans "Scan Barcode" %}', title,
{ {
onScan: function(response) { onScan: function(response) {
var url = response.url; // Pass the response to the calling function
if (options.onScan) {
options.onScan(response);
} else {
let url = response.url;
if (url) { if (url) {
$(modal).modal('hide'); $(modal).modal('hide');
@ -418,6 +429,7 @@ function barcodeScanDialog() {
); );
} }
} }
}
}, },
); );
} }
@ -428,7 +440,8 @@ function barcodeScanDialog() {
*/ */
function linkBarcodeDialog(data, options={}) { function linkBarcodeDialog(data, options={}) {
var modal = '#modal-form'; var modal = options.modal || createNewModal();
options.modal = modal;
barcodeDialog( barcodeDialog(
options.title, options.title,
@ -481,7 +494,8 @@ function unlinkBarcode(data, options={}) {
*/ */
function barcodeCheckInStockItems(location_id, 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 // List of items we are going to checkin
var items = []; var items = [];
@ -672,7 +686,9 @@ function barcodeCheckInStockItems(location_id, options={}) {
*/ */
function barcodeCheckInStockLocations(location_id, options={}) { function barcodeCheckInStockLocations(location_id, options={}) {
var modal = '#modal-form'; var modal = options.modal || createNewModal();
options.modal = modal;
var header = ''; var header = '';
barcodeDialog( barcodeDialog(
@ -725,7 +741,8 @@ function barcodeCheckInStockLocations(location_id, options={}) {
*/ */
function scanItemsIntoLocation(item_list, options={}) { function scanItemsIntoLocation(item_list, options={}) {
var modal = options.modal || '#modal-form'; var modal = options.modal || createNewModal();
options.modal = modal;
var stock_location = null; var stock_location = null;

View File

@ -210,7 +210,13 @@ function makeIconButton(icon, cls, pk, title, options={}) {
var html = ''; var html = '';
var extraProps = ''; var extraProps = options.extra || '';
var style = '';
if (options.hidden) {
style += `display: none;`;
}
if (options.disabled) { if (options.disabled) {
extraProps += `disabled='true' `; extraProps += `disabled='true' `;
@ -220,7 +226,7 @@ function makeIconButton(icon, cls, pk, title, options={}) {
extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`; extraProps += `data-bs-toggle='collapse' href='#${options.collapseTarget}'`;
} }
html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps}>`; html += `<button pk='${pk}' id='${id}' class='${classes}' title='${title}' ${extraProps} style='${style}'>`;
html += `<span class='fas ${icon}'></span>`; html += `<span class='fas ${icon}'></span>`;
html += `</button>`; html += `</button>`;

View File

@ -1010,19 +1010,6 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
quantity = 0; quantity = 0;
} }
// Prepend toggles to the quantity input
var toggle_batch = `
<span class='input-group-text' title='{% trans "Add batch code" %}' data-bs-toggle='collapse' href='#div-batch-${pk}'>
<span class='fas fa-layer-group'></span>
</span>
`;
var toggle_serials = `
<span class='input-group-text' title='{% trans "Add serial numbers" %}' data-bs-toggle='collapse' href='#div-serials-${pk}'>
<span class='fas fa-hashtag'></span>
</span>
`;
var units = line_item.part_detail.units || ''; var units = line_item.part_detail.units || '';
var pack_size = line_item.supplier_part_detail.pack_size || 1; var pack_size = line_item.supplier_part_detail.pack_size || 1;
var pack_size_div = ''; var pack_size_div = '';
@ -1031,7 +1018,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
if (pack_size != 1) { if (pack_size != 1) {
pack_size_div = ` pack_size_div = `
<div class='alert alert-block alert-info'> <div class='alert alert-small alert-block alert-info'>
{% trans "Pack Quantity" %}: ${pack_size} ${units}<br> {% trans "Pack Quantity" %}: ${pack_size} ${units}<br>
{% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units} {% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
</div>`; </div>`;
@ -1060,7 +1047,20 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
required: false, required: false,
label: '{% trans "Batch Code" %}', label: '{% trans "Batch Code" %}',
help_text: '{% trans "Enter batch code for incoming stock items" %}', 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, required: false,
label: '{% trans "Serial Numbers" %}', label: '{% trans "Serial Numbers" %}',
help_text: '{% trans "Enter serial numbers for incoming stock items" %}', 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}`;
var quantity_input_group = `${quantity_input}${pack_size_div}<div class='collapse' id='div-batch-${pk}'>${batch_input}</div>`;
if (line_item.part_detail.trackable) {
quantity_input_group += `<div class='collapse' id='div-serials-${pk}'>${sn_input}</div>`;
}
// Construct list of StockItem status codes // Construct list of StockItem status codes
var choices = []; var choices = [];
@ -1098,6 +1096,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
type: 'related field', type: 'related field',
label: '{% trans "Location" %}', label: '{% trans "Location" %}',
required: false, required: false,
icon: 'fa-sitemap',
}, },
{ {
hideLabels: true, hideLabels: true,
@ -1121,13 +1120,22 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
// Button to remove the row // Button to remove the row
let buttons = ''; 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( buttons += makeIconButton(
'fa-layer-group', 'fa-layer-group',
'button-row-add-batch', 'button-row-add-batch',
pk, pk,
'{% trans "Add batch code" %}', '{% trans "Add batch code" %}',
{ {
collapseTarget: `div-batch-${pk}` collapseTarget: `row-batch-${pk}`
} }
); );
@ -1138,7 +1146,7 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
pk, pk,
'{% trans "Add serial numbers" %}', '{% 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); buttons = wrapButtons(buttons);
let progress = makeProgressBar(line_item.received, line_item.quantity);
var html = ` var html = `
<tr id='receive_row_${pk}' class='stock-receive-row'> <tr id='receive_row_${pk}' class='stock-receive-row'>
<td id='part_${pk}'> <td id='part_${pk}'>
@ -1157,11 +1167,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<td id='sku_${pk}'> <td id='sku_${pk}'>
${line_item.supplier_part_detail.SKU} ${line_item.supplier_part_detail.SKU}
</td> </td>
<td id='on_order_${pk}'>
${line_item.quantity}
</td>
<td id='received_${pk}'> <td id='received_${pk}'>
${line_item.received} ${progress}
</td> </td>
<td id='quantity_${pk}'> <td id='quantity_${pk}'>
${quantity_input_group} ${quantity_input_group}
@ -1169,13 +1176,31 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<td id='status_${pk}'> <td id='status_${pk}'>
${status_input} ${status_input}
</td> </td>
<td id='desination_${pk}'>
${destination_input}
</td>
<td id='actions_${pk}'> <td id='actions_${pk}'>
${barcode_input}
${buttons} ${buttons}
</td> </td>
</tr>`; </tr>
<!-- Hidden rows for extra data entry -->
<tr id='row-destination-${pk}' class='collapse'>
<td colspan='2'></td>
<th>{% trans "Location" %}</th>
<td colspan='2'>${destination_input}</td>
<td></td>
</tr>
<tr id='row-batch-${pk}' class='collapse'>
<td colspan='2'></td>
<th>{% trans "Batch" %}</th>
<td colspan='2'>${batch_input}</td>
<td></td>
</tr>
<tr id='row-serials-${pk}' class='collapse'>
<td colspan='2'></td>
<th>{% trans "Serials" %}</th>
<td colspan=2'>${sn_input}</td>
<td></td>
</tr>
`;
return html; return html;
} }
@ -1192,16 +1217,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
// Add table // Add table
html += ` html += `
<table class='table table-striped table-condensed' id='order-receive-table'> <table class='table table-condensed' id='order-receive-table'>
<thead> <thead>
<tr> <tr>
<th>{% trans "Part" %}</th> <th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th> <th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th> <th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th> <th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th> <th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -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 // Add callbacks to remove rows
$(opts.modal).find('.button-row-remove').click(function() { $(opts.modal).find('.button-row-remove').click(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
@ -1304,10 +1365,9 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
var pk = item.pk; var pk = item.pk;
// Extract data for each line
var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts); var quantity = getFormFieldValue(`items_quantity_${pk}`, {}, opts);
var status = getFormFieldValue(`items_status_${pk}`, {}, opts); var status = getFormFieldValue(`items_status_${pk}`, {}, opts);
var location = getFormFieldValue(`items_location_${pk}`, {}, opts); var location = getFormFieldValue(`items_location_${pk}`, {}, opts);
if (quantity != null) { if (quantity != null) {
@ -1319,6 +1379,10 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
location: location, location: location,
}; };
if (global_settings.BARCODE_ENABLE) {
line.barcode = getFormFieldValue(`items_barcode_${pk}`, {}, opts);
}
if (getFormFieldElement(`items_batch_code_${pk}`).exists()) { if (getFormFieldElement(`items_batch_code_${pk}`).exists()) {
line.batch_code = getFormFieldValue(`items_batch_code_${pk}`); line.batch_code = getFormFieldValue(`items_batch_code_${pk}`);
} }