diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index db4dd23..2a00d2b 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1159,5 +1159,11 @@ export const printedpartsApi = { }, adjust(printeditemid, data) { return api.post(`/printedparts/items/${printeditemid}/adjust`, data) + }, + kioskItem(itemcode) { + return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`) + }, + kioskTake(data) { + return api.post('/printedparts/kiosk/take', data) } } diff --git a/frontend/src/components/TouchKeypad.vue b/frontend/src/components/TouchKeypad.vue new file mode 100644 index 0000000..632dcd6 --- /dev/null +++ b/frontend/src/components/TouchKeypad.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ade1a1e..f472551 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -65,6 +65,14 @@ const routes = [ name: 'shopfloor', component: () => import('../views/ShopfloorDashboard.vue') }, + { + // Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad. + // Open on purpose - see the decision record in the printedparts proposal. + path: '/parts-kiosk', + name: 'parts-kiosk', + component: () => import('../views/printedparts/PartsKiosk.vue'), + meta: { plugin: 'printedparts' } + }, { path: '/tv', name: 'tv', diff --git a/frontend/src/views/printedparts/PartsKiosk.vue b/frontend/src/views/printedparts/PartsKiosk.vue new file mode 100644 index 0000000..66a31f0 --- /dev/null +++ b/frontend/src/views/printedparts/PartsKiosk.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md index 0fd0cf6..9cec4a1 100644 --- a/plugins/printedparts/README.md +++ b/plugins/printedparts/README.md @@ -41,3 +41,19 @@ pytest plugins/printedparts/tests/ - `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough - `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract - `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules + +## Why the kiosk take endpoint is unauthenticated + +`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every +other kiosk endpoint is a read). Accepted deliberately, against the criteria +in docs/proposals/printedparts-plugin.md: + +1. Decrement-only: it can reduce stock of an active item, nothing else. +2. Fully attributed: it refuses to act without a badge that resolves under + the site policy; every action lands in the ledger with SSO + name + time. +3. Bounded blast radius: worst case is stock counts driven low - visible in + the ledger and reversible with an adjust. +4. Physically rate-limited: it serves a touch screen on the shop floor; + nothing enumerable, nothing worth scraping. + +Any future open-write endpoint must clear the same bar. diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 988035c..acfce9e 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -295,3 +295,54 @@ def adjust_item(item_id: int): http_code=422) _ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason) return success_response(item.to_dict(), message='Stock adjusted') + + +# --- kiosk: UNauthenticated by decision record -------------------------------- +# The take endpoint is the product's first open WRITE. The proposal's decision +# record sets the bar it must meet: decrement-only, badge-attributed, bounded, +# physically rate-limited. It can reduce stock of an active item and nothing +# else; identity comes from the badge resolved server-side, never the client. + +@printedparts_bp.route('/kiosk/item/', methods=['GET']) +def kiosk_item(itemcode): + """Item summary for a scanned bin barcode (open read for the kiosk).""" + item = PrintedItem.query.filter( + PrintedItem.itemcode == itemcode.strip(), + PrintedItem.isactive == True).first() + if not item: + return error_response(ErrorCodes.NOT_FOUND, + 'No part matches that barcode', http_code=404) + return success_response(item.to_dict()) + + +@printedparts_bp.route('/kiosk/take', methods=['POST']) +def kiosk_take(): + """Take parts from a bin. Body: {itemcode, badge, quantity}.""" + data = request.get_json() or {} + + item = PrintedItem.query.filter( + PrintedItem.itemcode == (data.get('itemcode') or '').strip(), + PrintedItem.isactive == True).first() + if not item: + return error_response(ErrorCodes.NOT_FOUND, + 'No part matches that barcode', http_code=404) + + quantity = data.get('quantity') + if not isinstance(quantity, int) or quantity < 1: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Enter how many you are taking') + if quantity > item.quantityonhand: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Only {item.quantityonhand} on hand - take fewer or see the ' + f'parts team') + + try: + sso, name = resolve_badge(data.get('badge')) + except BadgeError as badge_error: + return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), + http_code=422) + + _ledger_write(item, 'take', -quantity, sso, name) + return success_response(item.to_dict(), + message=f'Took {quantity}, {item.quantityonhand} left') diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index 81fa7db..790e8d7 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -128,3 +128,41 @@ def test_member_without_permission_gets_403(client, member_headers, item): assert client.post(f'/api/printedparts/items/{item}/restock', json={'quantity': 1, 'badge': '1'}, headers=member_headers).status_code == 403 + + +def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item, + directory_employee): + """The kiosk endpoint needs no auth but only ever decrements stock.""" + itemcode = '3DP-9001' + stocked = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 5, 'badge': directory_employee}, + headers=auth_headers) + assert stocked.status_code == 200 + + lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}') + assert lookup.status_code == 200 + + take = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2}) + assert take.status_code == 200, take.get_json() + assert take.get_json()['data']['quantityonhand'] == 3 + + too_many = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99}) + assert too_many.status_code == 400 + + unknown = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': '111111111', 'quantity': 1}) + assert unknown.status_code == 422 + + with app.app_context(): + rows = PrintedItemTransaction.query.filter_by( + printeditemid=item, transactiontype='take').all() + assert len(rows) == 1 + assert rows[0].quantitychange == -2 + assert rows[0].employeename == 'Pat Printer' + cached = db.session.get(PrintedItem, item).quantityonhand + ledgersum = sum(r.quantitychange for r in + PrintedItemTransaction.query.filter_by( + printeditemid=item).all()) + assert cached == ledgersum