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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user