mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
7af2bb4e8c
commit
1088b9c947
@ -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
|
||||
|
||||
|
@ -784,6 +784,12 @@ input[type="submit"] {
|
||||
|
||||
.alert-block {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-small {
|
||||
padding: 0.35rem;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.navbar .btn {
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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>`;
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user