Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -75,6 +75,7 @@ def get_item(item_id: int):
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
@@ -257,6 +258,52 @@ from ..models import PrintedItemTransaction
|
||||
from ..services.badges import BadgeError, resolve_badge
|
||||
|
||||
|
||||
class InsufficientStock(Exception):
|
||||
"""A decrement was refused because the row no longer holds that many.
|
||||
|
||||
Carries the quantity the DATABASE holds, not the stale one the caller read,
|
||||
so the message the operator sees is the truth at the moment of the write.
|
||||
"""
|
||||
|
||||
def __init__(self, available):
|
||||
super().__init__('insufficient stock')
|
||||
self.available = available
|
||||
|
||||
|
||||
def _move_quantity(item, quantitychange):
|
||||
"""Move quantityonhand by quantitychange in ONE statement, and let the row
|
||||
itself refuse a decrement it cannot cover.
|
||||
|
||||
This is the guard, not the caller's earlier `quantity > item.quantityonhand`
|
||||
check. That check reads, decides in Python, and writes - so two kiosk takes
|
||||
that interleave both read the same on-hand figure and both pass it. Five
|
||||
concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own
|
||||
5 - 5 = 0, and quantityonhand ended up permanently out of step with the
|
||||
ledger that recorded all five.
|
||||
|
||||
Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database
|
||||
do the comparing at write time, where it is atomic. A row that no longer
|
||||
holds enough matches nothing, rowcount is 0, and the take is refused.
|
||||
Returns the resulting quantity.
|
||||
"""
|
||||
query = PrintedItem.query.filter(
|
||||
PrintedItem.printeditemid == item.printeditemid)
|
||||
if quantitychange < 0:
|
||||
query = query.filter(PrintedItem.quantityonhand >= -quantitychange)
|
||||
moved = query.update(
|
||||
{PrintedItem.quantityonhand:
|
||||
PrintedItem.quantityonhand + quantitychange},
|
||||
synchronize_session=False)
|
||||
if not moved:
|
||||
# Read back what is actually there so the refusal can say so.
|
||||
db.session.rollback()
|
||||
available = db.session.query(PrintedItem.quantityonhand).filter(
|
||||
PrintedItem.printeditemid == item.printeditemid).scalar() or 0
|
||||
raise InsufficientStock(available)
|
||||
db.session.refresh(item)
|
||||
return item.quantityonhand
|
||||
|
||||
|
||||
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
||||
revision=None):
|
||||
"""Append a ledger row and move the cached quantity in ONE commit.
|
||||
@@ -266,9 +313,17 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
||||
alert when this write CROSSES the item's threshold downward - crossing
|
||||
(not being below) is the natural debounce: one alert per depletion, and
|
||||
restocking above the threshold rearms it.
|
||||
|
||||
Raises InsufficientStock when the row cannot cover the decrement. The
|
||||
caller has already refused the obvious case; this is the one that only
|
||||
shows up under concurrency.
|
||||
"""
|
||||
quantitybefore = item.quantityonhand
|
||||
item.quantityonhand += quantitychange
|
||||
# Derived from what the write ACTUALLY produced, not from what this request
|
||||
# read earlier. Under concurrency the two differ, and the threshold
|
||||
# crossing has to be judged on the real numbers or the alert fires for the
|
||||
# wrong take (or not at all).
|
||||
quantityafter = _move_quantity(item, quantitychange)
|
||||
quantitybefore = quantityafter - quantitychange
|
||||
db.session.add(PrintedItemTransaction(
|
||||
printeditemid=item.printeditemid,
|
||||
transactiontype=transactiontype,
|
||||
@@ -427,7 +482,13 @@ def adjust_item(item_id: int):
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
http_code=422)
|
||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
||||
try:
|
||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
||||
except InsufficientStock as shortfall:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Adjustment would drive stock below zero '
|
||||
f'(on hand: {shortfall.available})')
|
||||
return success_response(item.to_dict(), message='Stock adjusted')
|
||||
|
||||
|
||||
@@ -500,8 +561,14 @@ def _kiosk_find_item(itemcode):
|
||||
matches = []
|
||||
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
||||
for value in (candidate.itemcode, candidate.gagelabtag):
|
||||
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
||||
if tail and int(tail) == wanted:
|
||||
# The TRAILING digit run, not every digit in the string. A
|
||||
# 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)
|
||||
break
|
||||
if len(matches) == 1:
|
||||
@@ -579,7 +646,16 @@ def kiosk_take():
|
||||
except (ValueError, TypeError):
|
||||
revision = None
|
||||
|
||||
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
||||
try:
|
||||
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
||||
except InsufficientStock as shortfall:
|
||||
# Another kiosk took the last of it between this request's read and its
|
||||
# write. Same message as the pre-check, with the number the database
|
||||
# actually holds now.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Only {shortfall.available} on hand - take fewer or see the '
|
||||
f'parts team')
|
||||
return success_response(item.to_dict(),
|
||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
||||
|
||||
|
||||
@@ -7,10 +7,31 @@
|
||||
</button>
|
||||
</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 -->
|
||||
<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>
|
||||
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
||||
numpad, and the on-screen keypad all type into it. -->
|
||||
@@ -108,6 +129,13 @@ const item = ref(null)
|
||||
const badge = ref('')
|
||||
const quantity = 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 submitting = ref(false)
|
||||
const manualCode = ref('')
|
||||
@@ -118,6 +146,14 @@ let resetTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
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
|
||||
// label format is a property of the site's physical tags, not something that
|
||||
// changes during a shift.
|
||||
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
|
||||
try {
|
||||
const response = await printedpartsApi.kioskItem(code)
|
||||
item.value = response.data.data
|
||||
misses.value = 0
|
||||
step.value = 'badge'
|
||||
manualCode.value = ''
|
||||
} catch (lookupError) {
|
||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||
'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()
|
||||
}
|
||||
@@ -215,6 +257,14 @@ function reset() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kiosk-unprovisioned {
|
||||
text-align: center;
|
||||
}
|
||||
.kiosk-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.parts-kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
|
||||
@@ -36,16 +36,24 @@ class BadgeError(ValueError):
|
||||
|
||||
|
||||
def _parse_badge(badge):
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
|
||||
badge = (badge or '').strip()
|
||||
if not badge:
|
||||
raise BadgeError('Scan or enter a badge')
|
||||
if badge.isdigit():
|
||||
return 'sso', badge
|
||||
match = _PAYNO_BADGE.match(badge)
|
||||
if match:
|
||||
return 'payno', match.group(1)
|
||||
raise BadgeError('Unrecognized badge format')
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
|
||||
|
||||
The shapes themselves live in shopdb.api (normalise_badge), because the USB
|
||||
kiosk reads the same physical badges and used to disagree: it took the
|
||||
string raw, so the PayNo wrapper resolved to nobody and an operator whose
|
||||
scanner emits it could take a part and not check out a drive.
|
||||
|
||||
That also closed a subtler hole. This used str.isdigit(), which is true for
|
||||
Arabic-Indic and other decimal forms, so a badge written in them was
|
||||
accepted, resolved against int() to a REAL employee, and then stored as an
|
||||
SSO string that joins to nothing.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, badge_kind, normalise_badge
|
||||
|
||||
try:
|
||||
return badge_kind(badge), normalise_badge(badge)
|
||||
except BadgeShapeError as bad:
|
||||
raise BadgeError(str(bad))
|
||||
|
||||
|
||||
def _selfhosted_lookup(digits):
|
||||
|
||||
Reference in New Issue
Block a user