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()
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,6 +754,7 @@ class InvenTreeBarcodeMixin(models.Model):
self.barcode_hash = barcode_hash
if save:
self.save()
return True

View File

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

View File

@ -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,8 +600,14 @@ 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)

View File

@ -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

View File

@ -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."""

View File

@ -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);
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,16 +401,22 @@ 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;
// Pass the response to the calling function
if (options.onScan) {
options.onScan(response);
} else {
let url = response.url;
if (url) {
$(modal).modal('hide');
@ -418,6 +429,7 @@ function barcodeScanDialog() {
);
}
}
}
},
);
}
@ -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;

View File

@ -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 += `<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 += `</button>`;

View File

@ -1010,19 +1010,6 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
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 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 = `
<div class='alert alert-block alert-info'>
<div class='alert alert-small alert-block alert-info'>
{% trans "Pack Quantity" %}: ${pack_size} ${units}<br>
{% trans "Received Quantity" %}: <span class='pack_received_quantity' id='items_received_quantity_${pk}'>${received}</span> ${units}
</div>`;
@ -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}<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>`;
}
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 = `
<tr id='receive_row_${pk}' class='stock-receive-row'>
<td id='part_${pk}'>
@ -1157,11 +1167,8 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<td id='sku_${pk}'>
${line_item.supplier_part_detail.SKU}
</td>
<td id='on_order_${pk}'>
${line_item.quantity}
</td>
<td id='received_${pk}'>
${line_item.received}
${progress}
</td>
<td id='quantity_${pk}'>
${quantity_input_group}
@ -1169,13 +1176,31 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
<td id='status_${pk}'>
${status_input}
</td>
<td id='desination_${pk}'>
${destination_input}
</td>
<td id='actions_${pk}'>
${barcode_input}
${buttons}
</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;
}
@ -1192,16 +1217,14 @@ function receivePurchaseOrderItems(order_id, line_items, options={}) {
// Add table
html += `
<table class='table table-striped table-condensed' id='order-receive-table'>
<table class='table table-condensed' id='order-receive-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Order Code" %}</th>
<th>{% trans "Ordered" %}</th>
<th>{% trans "Received" %}</th>
<th style='min-width: 50px;'>{% trans "Quantity to Receive" %}</th>
<th style='min-width: 150px;'>{% trans "Status" %}</th>
<th style='min-width: 300px;'>{% trans "Destination" %}</th>
<th></th>
</tr>
</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
$(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}`);
}