Match the number on the bin, and let an unset kiosk say so
The last two kiosk findings. Keypad entry collected EVERY digit in an item's code, and the shipped prefix contains one: '3DP0004' collapsed to 30004, so an operator typing the 4 printed on the label was told no part matches that barcode and had to work out that the leading 3 belonged to the prefix. The number on a label is the number at the end, so the trailing digit run is what is compared now. An ambiguous number is still refused rather than guessed - taking from the wrong bin is worse than being asked to scan the label. A parts kiosk that had never been provisioned drew its normal "Scan the barcode on the bin" screen and answered "Not found" to every scan, which reads as "we do not stock that part" rather than "this screen was never set up". The server cannot say which: the routes answer a bare 404 on purpose, so a caller without the device token does not learn the door exists, and that stays. But the BROWSER knows whether it holds a token, and saying so discloses nothing - an unprovisioned screen now says it is not set up and how to fix it. For the case the client genuinely cannot distinguish - kiosk switched off versus a part that is not stocked, both a 404 by design - three misses in a row add a line suggesting the kiosk may have been switched off. Truthful without the server having to leak anything. Three tests, verified by planting the all-digits match back. Scope: product
This commit is contained in:
@@ -75,6 +75,7 @@ def get_item(item_id: int):
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -560,8 +561,14 @@ def _kiosk_find_item(itemcode):
|
|||||||
matches = []
|
matches = []
|
||||||
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
||||||
for value in (candidate.itemcode, candidate.gagelabtag):
|
for value in (candidate.itemcode, candidate.gagelabtag):
|
||||||
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
# The TRAILING digit run, not every digit in the string. A
|
||||||
if tail and int(tail) == wanted:
|
# prefix can contain digits - the shipped default is '3DP' -
|
||||||
|
# and collecting all of them turned 3DP0004 into 30004, so the
|
||||||
|
# operator typing the 4 printed on the bin got "no part matches
|
||||||
|
# that barcode" and had to work out that the 3 belonged to the
|
||||||
|
# prefix. The number on the label is the number at the end.
|
||||||
|
digits = re.search(r'(\d+)$', (value or '').strip())
|
||||||
|
if digits and int(digits.group(1)) == wanted:
|
||||||
matches.append(candidate)
|
matches.append(candidate)
|
||||||
break
|
break
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
|
|||||||
@@ -7,10 +7,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
<div v-if="error" class="kiosk-error">
|
||||||
|
{{ error }}
|
||||||
|
<p v-if="misses >= 3" class="kiosk-hint">
|
||||||
|
Several scans in a row have found nothing. The kiosk may have been
|
||||||
|
switched off, or this screen's setup link may have been replaced - ask
|
||||||
|
an administrator to check Settings > 3D Printed Parts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- This screen was never provisioned. The SERVER cannot say so - the
|
||||||
|
routes answer a bare 404 on purpose, so a caller without the token
|
||||||
|
does not learn the door exists - but the browser knows whether it
|
||||||
|
holds one, and that is not a disclosure. Without this the kiosk drew
|
||||||
|
its normal scan prompt and answered "Not found" to every scan, which
|
||||||
|
reads as "we do not stock that part". -->
|
||||||
|
<section v-if="!provisioned" class="kiosk-step kiosk-unprovisioned">
|
||||||
|
<p class="kiosk-prompt">This screen is not set up yet</p>
|
||||||
|
<p class="kiosk-hint">
|
||||||
|
An administrator opens it once using the kiosk link from
|
||||||
|
Settings > 3D Printed Parts. The screen remembers it after that.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- step 1: the bin label -->
|
<!-- step 1: the bin label -->
|
||||||
<section v-if="step === 'item'" class="kiosk-step">
|
<section v-if="provisioned && step === 'item'" class="kiosk-step">
|
||||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||||
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
||||||
numpad, and the on-screen keypad all type into it. -->
|
numpad, and the on-screen keypad all type into it. -->
|
||||||
@@ -108,6 +129,13 @@ const item = ref(null)
|
|||||||
const badge = ref('')
|
const badge = ref('')
|
||||||
const quantity = ref('')
|
const quantity = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
// Whether THIS browser holds the kiosk's device token. The server will not say
|
||||||
|
// (see the template), and it does not need to: an unprovisioned screen should
|
||||||
|
// say so rather than pretending to be a working kiosk with an empty catalog.
|
||||||
|
const provisioned = ref(true)
|
||||||
|
// Consecutive lookups that found nothing. Every scan missing usually means the
|
||||||
|
// kiosk was switched off, not that no bin is known.
|
||||||
|
const misses = ref(0)
|
||||||
const doneMessage = ref('')
|
const doneMessage = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const manualCode = ref('')
|
const manualCode = ref('')
|
||||||
@@ -118,6 +146,14 @@ let resetTimer = null
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
||||||
|
try {
|
||||||
|
provisioned.value = Boolean(
|
||||||
|
localStorage.getItem(KIOSK_TOKEN_KEYS['/printedparts/kiosk/']))
|
||||||
|
} catch {
|
||||||
|
// A browser refusing localStorage cannot be provisioned at all, and
|
||||||
|
// saying so is more use than a scan box that answers nothing.
|
||||||
|
provisioned.value = false
|
||||||
|
}
|
||||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||||
// label format is a property of the site's physical tags, not something that
|
// label format is a property of the site's physical tags, not something that
|
||||||
// changes during a shift.
|
// changes during a shift.
|
||||||
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
|
|||||||
try {
|
try {
|
||||||
const response = await printedpartsApi.kioskItem(code)
|
const response = await printedpartsApi.kioskItem(code)
|
||||||
item.value = response.data.data
|
item.value = response.data.data
|
||||||
|
misses.value = 0
|
||||||
step.value = 'badge'
|
step.value = 'badge'
|
||||||
manualCode.value = ''
|
manualCode.value = ''
|
||||||
} catch (lookupError) {
|
} catch (lookupError) {
|
||||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||||
'No part matches that barcode'
|
'No part matches that barcode'
|
||||||
|
// The gate and a genuine miss both answer 404, deliberately, so the client
|
||||||
|
// cannot tell them apart from one response. What it CAN say is that
|
||||||
|
// repeated misses may mean the kiosk was switched off rather than that
|
||||||
|
// every bin is unknown.
|
||||||
|
misses.value += 1
|
||||||
}
|
}
|
||||||
focusEntry()
|
focusEntry()
|
||||||
}
|
}
|
||||||
@@ -215,6 +257,14 @@ function reset() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.kiosk-unprovisioned {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.kiosk-hint {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
.parts-kiosk {
|
.parts-kiosk {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -594,3 +594,61 @@ def test_a_take_that_passed_its_precheck_is_still_refused_by_the_row(
|
|||||||
ledger = sum(t.quantitychange for t in after.transactions)
|
ledger = sum(t.quantitychange for t in after.transactions)
|
||||||
assert ledger == after.quantityonhand, \
|
assert ledger == after.quantityonhand, \
|
||||||
'quantityonhand and the ledger must agree'
|
'quantityonhand and the ledger must agree'
|
||||||
|
|
||||||
|
|
||||||
|
# --- the number on the bin is the number you type ---------------------------
|
||||||
|
#
|
||||||
|
# Keypad entry matched the digits ANYWHERE in the code, and the shipped prefix
|
||||||
|
# contains one: '3DP0004' collapsed to 30004, so an operator typing the 4
|
||||||
|
# printed on the label was told no part matches that barcode, and had to work
|
||||||
|
# out that the leading 3 belonged to the prefix.
|
||||||
|
|
||||||
|
def test_the_keypad_matches_the_number_printed_on_the_label(client, db,
|
||||||
|
auth_headers,
|
||||||
|
kiosk,
|
||||||
|
directory_employee):
|
||||||
|
from plugins.printedparts.models import PrintedItem
|
||||||
|
|
||||||
|
created = client.post('/api/printedparts/items', headers=auth_headers,
|
||||||
|
json={'itemname': 'Keypad clip'})
|
||||||
|
assert created.status_code == 201, created.get_json()
|
||||||
|
itemcode = created.get_json()['data']['itemcode']
|
||||||
|
assert itemcode.startswith('3DP'), itemcode
|
||||||
|
|
||||||
|
number = str(int(itemcode[3:]))
|
||||||
|
found = client.get('/api/printedparts/kiosk/item/%s' % number,
|
||||||
|
headers=KIOSK_HEADERS)
|
||||||
|
assert found.status_code == 200, (number, found.get_json())
|
||||||
|
assert found.get_json()['data']['itemcode'] == itemcode
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_full_code_still_works(client, db, auth_headers, kiosk):
|
||||||
|
created = client.post('/api/printedparts/items', headers=auth_headers,
|
||||||
|
json={'itemname': 'Full code clip'})
|
||||||
|
itemcode = created.get_json()['data']['itemcode']
|
||||||
|
|
||||||
|
found = client.get('/api/printedparts/kiosk/item/%s' % itemcode,
|
||||||
|
headers=KIOSK_HEADERS)
|
||||||
|
assert found.status_code == 200, found.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ambiguous_number_is_still_refused(client, db, auth_headers, kiosk):
|
||||||
|
"""Two items whose trailing numbers agree must not resolve to a guess -
|
||||||
|
taking from the wrong bin is worse than being told to scan the label."""
|
||||||
|
from plugins.printedparts.models import PrintedItem
|
||||||
|
|
||||||
|
first = client.post('/api/printedparts/items', headers=auth_headers,
|
||||||
|
json={'itemname': 'Ambiguous one'}).get_json()['data']
|
||||||
|
item = db.session.get(PrintedItem, first['printeditemid'])
|
||||||
|
item.gagelabtag = 'WJRP%04d' % 7777
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
second = client.post('/api/printedparts/items', headers=auth_headers,
|
||||||
|
json={'itemname': 'Ambiguous two'}).get_json()['data']
|
||||||
|
other = db.session.get(PrintedItem, second['printeditemid'])
|
||||||
|
other.gagelabtag = 'GAGE%04d' % 7777
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
refused = client.get('/api/printedparts/kiosk/item/7777',
|
||||||
|
headers=KIOSK_HEADERS)
|
||||||
|
assert refused.status_code == 404, refused.get_json()
|
||||||
|
|||||||
Reference in New Issue
Block a user