The kiosk take endpoint is the product's first unauthenticated mutation; spell out the acceptance criteria (decrement-only, badge attributed, bounded, physically rate-limited) so future open-write endpoints meet the same bar. The dashboard-widget milestone is marked optional: get_dashboard_widgets predates the ADR-010 data-only renderers and needs a core component to render.
209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# Proposal: printedparts plugin (3D-printed parts storefront + kiosk)
|
|
|
|
Status: PROPOSED (also serves as the reference design for the plugin-development
|
|
lab in `docs/PLUGIN-LAB-PRINTEDPARTS.md`)
|
|
|
|
## 1. Problem
|
|
|
|
The 3D-printer engineers stock bins of printed parts (fixtures, clips, covers,
|
|
spacers). Anyone on the floor can take parts, so stock silently runs out and
|
|
nobody knows who took what or how fast items burn down. They need:
|
|
|
|
- a catalog ("storefront") of printable items: photo, description, quantity on
|
|
hand;
|
|
- a barcode label per item (1in x 0.5in) stuck on each bin;
|
|
- a touch-screen kiosk: scan the bin barcode, scan your badge, enter how many
|
|
you took, submit;
|
|
- restock and correction flows for the engineers;
|
|
- stock monitoring plus consumption metrics.
|
|
|
|
## 2. Shape: standalone model plugin, NOT an asset type
|
|
|
|
These are quantity-based consumables: one row represents a *kind* of part with
|
|
a count, not an individually tracked machine. ADR-001 assets are one-row-per-
|
|
physical-thing (a PC, a printer). So printedparts follows the
|
|
knowledgebase/usb shape - own tables, own blueprint, no AssetType row - and
|
|
does NOT join the asset-label TYPE_CONFIG; it ships its own print view the way
|
|
USB labels do.
|
|
|
|
Item identity: `itemcode`, generated `3DP-<zero-padded id>` (prefix
|
|
configurable via setting `printedparts_code_prefix`). Short, CODE128-friendly,
|
|
human-readable. This is what the bin label encodes.
|
|
|
|
## 3. Data model (2 tables, LOCKED naming, per-plugin Alembic)
|
|
|
|
### printeditems
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| printeditemid | int PK autoincrement | |
|
|
| itemcode | varchar(20) unique, indexed | generated on create |
|
|
| itemname | varchar(120) NOT NULL | |
|
|
| itemdescription | varchar(500) | brief description |
|
|
| imageurl | varchar(255) | served upload, models.py pattern |
|
|
| quantityonhand | int NOT NULL default 0 | cached; ledger is truth |
|
|
| lowstockthreshold | int NOT NULL default 5 | per-item, seeds from setting |
|
|
| binlocation | varchar(100) | where the bin lives |
|
|
| printnotes | mediumtext | material, print time, slicer file path |
|
|
| isactive | tinyint(1) | soft retire |
|
|
| createddate / modifieddate | datetime | AuditMixin/BaseModel |
|
|
|
|
### printeditemtransactions (the ledger - source of truth)
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| transactionid | int PK | |
|
|
| printeditemid | int FK -> printeditems CASCADE, indexed | |
|
|
| transactiontype | varchar(10) NOT NULL | take / restock / adjust |
|
|
| quantitychange | int NOT NULL | negative for take, signed for adjust |
|
|
| employeesso | varchar(20) NOT NULL, indexed | who (badge-resolved) |
|
|
| employeename | varchar(120) | resolved at write time (USB pattern) |
|
|
| reason | varchar(255) | required for adjust |
|
|
| transactiondate | datetime NOT NULL default naive-UTC, indexed | |
|
|
|
|
Invariants: `quantityonhand` = sum of `quantitychange` (enforced by writing
|
|
both in one session/commit; an `adjust` can never drive it below 0 - reject).
|
|
Every write records WHO via badge scan; there is no anonymous mutation.
|
|
|
|
Both tables registered in `PLUGIN_TABLE_OWNERS`
|
|
(`shopdb/plugins/alembic_template.py`); migration 0001 is a REAL baseline
|
|
(measuringtools pattern - hand-written `op.create_table`, no cross-schema FK
|
|
so `create_plugin_tables` would also work, but write the ops explicitly for
|
|
the exercise).
|
|
|
|
## 4. Badge resolution (reuse the USB contract exactly)
|
|
|
|
Same input shapes as `plugins/usb/api/routes.py`:
|
|
- all digits -> SSO;
|
|
- `0<digits>BZ` (case-insensitive) -> physical badge wrapping a PayNo;
|
|
- resolution to a display name via the employees plugin directory
|
|
(`DirectoryEmployee`, selfhosted mode) with graceful "" fallback.
|
|
|
|
Extract-or-copy decision for the lab: copy the small `_PAYNO_BADGE` regex +
|
|
lookup into the plugin (contract-pure, no cross-plugin import of usb).
|
|
Resolution happens SERVER-side on the kiosk endpoint - the kiosk client never
|
|
supplies a name, only the raw badge string.
|
|
|
|
Manifest `dependencies: ["employees"]` (name lookup). Badge that resolves to
|
|
no employee: configurable policy setting `printedparts_unknown_badge`
|
|
(`allow` = record SSO with empty name, `deny` = 422). Default deny.
|
|
|
|
## 5. API surface (blueprint at /api/printedparts)
|
|
|
|
Authenticated management (JWT + permission):
|
|
| route | method | permission |
|
|
|---|---|---|
|
|
| `/items` | GET list (search, paginate, lowstock filter) | open read (jwt optional) |
|
|
| `/items/<id>` | GET detail + recent transactions | open read |
|
|
| `/items` | POST create (mints itemcode) | printedparts.create |
|
|
| `/items/<id>` | PUT update | printedparts.edit |
|
|
| `/items/<id>` | DELETE soft-retire | printedparts.delete |
|
|
| `/items/<id>/image` | POST/DELETE upload/remove | printedparts.edit |
|
|
| `/image/<filename>` | GET serve | public (models.py pattern) |
|
|
| `/items/<id>/restock` | POST {quantity, badge} | printedparts.restock |
|
|
| `/items/<id>/adjust` | POST {quantitychange, reason, badge} | printedparts.restock |
|
|
| `/items/<id>/transactions` | GET history, ?format=csv | open read |
|
|
|
|
Kiosk (unauthenticated, notifications/employees open-endpoint precedent):
|
|
| route | method | body |
|
|
|---|---|---|
|
|
| `/kiosk/item/<itemcode>` | GET | item summary by scanned code |
|
|
| `/kiosk/take` | POST | {itemcode, badge, quantity} |
|
|
|
|
`/kiosk/take` validation: item exists + active; quantity 1..quantityonhand
|
|
(clamp/reject configurable? no - reject with clear message, kiosk shows it);
|
|
badge resolves per policy. Writes ledger row (negative) + decrements cached
|
|
quantity in one commit. Rate of abuse is low (plant floor), but the endpoint
|
|
only ever DECREMENTS stock with a recorded badge - it cannot edit the catalog.
|
|
|
|
Permissions declared via `get_permissions()`: printedparts.view/create/edit/
|
|
delete/restock (category `printedparts`), seeded on install/enable.
|
|
|
|
## 6. Frontend
|
|
|
|
Management pages (scaffold output, standard layout, master templates
|
|
PrintersList/PrinterDetail):
|
|
- `PrintedItemsList.vue` - table: image thumb, code, name, qty (red badge when
|
|
<= threshold), bin; filters: search, low-stock-only; row click -> detail.
|
|
- `PrintedItemDetail.vue` - hero image + fields, transaction history table,
|
|
restock/adjust buttons (modal w/ quantity + badge + reason).
|
|
- `PrintedItemForm.vue` - create/edit incl. image upload, threshold, bin.
|
|
- Router file `router/routes/printedparts.js`, list/detail plugin-gated only,
|
|
new/edit + requiresAuth (ADR-009, usb.js precedent).
|
|
- Nav via `get_navigation_items()` -> "3D Parts".
|
|
|
|
Kiosk (net-new, top-level route `/parts-kiosk`, NO requiresAuth, outside
|
|
AppLayout - shopfloor precedent):
|
|
- Full-screen, 3-step flow: (1) SCAN ITEM - a focused invisible input catches
|
|
the keyboard-wedge scan of the bin barcode, shows item card w/ photo + qty;
|
|
(2) SCAN BADGE - same wedge input pattern for the badge; (3) QUANTITY - big
|
|
touch keypad (0-9, clear, backspace - net-new component
|
|
`TouchKeypad.vue`) + TAKE button. Success screen w/ remaining count, auto
|
|
reset after a few seconds. All state client-side; one POST at the end.
|
|
- Scanner UX rule: keyboard-wedge scanners type the code + Enter. A hidden
|
|
always-focused input with @keydown.enter handles both scans; on-screen
|
|
prompt tells the user what to scan. Touch fallback: item search + manual
|
|
badge entry (small link, for damaged labels).
|
|
|
|
Labels (own print view, USBLabelBatch precedent):
|
|
- `/print/printedparts-labels` public print route.
|
|
- NEW physical size: 1in x 0.5in stock -> `@page { size: 1in 0.5in; margin: 0 }`
|
|
one label per page (label printers feed roll stock; per-page = per-label).
|
|
Layout: CODE128 barcode (JsBarcode, ~0.9in x 0.28in, displayValue false) +
|
|
itemcode text under it (~7pt) + optional item name truncated. QR variant
|
|
offered but barcode is default at this size (a 0.4in QR is at the edge of
|
|
scanner tolerance; CODE128 of `3DP-0042` is comfortable).
|
|
- Batch mode: pick items -> one label per page sequence for roll printers;
|
|
also a ULINE mini-grid fallback for sheet printers (reuse mini72 pattern).
|
|
|
|
## 7. Metrics / reports (get_reports hook)
|
|
|
|
- `printedparts-stock` - current stock levels w/ threshold flags (CSV).
|
|
- `printedparts-consumption` - takes per item over a date range (CSV).
|
|
- `printedparts-by-person` - takes grouped by employee (CSV).
|
|
- Dashboard widget via `get_dashboard_widgets()`: low-stock item count.
|
|
- Nice-to-have later: burn-rate (avg takes/week per item + weeks-to-empty
|
|
projection) - plain SQL over the ledger, add once basics work.
|
|
|
|
## 8. Settings (get_settings_cards, category printedparts)
|
|
|
|
| key | default | purpose |
|
|
|---|---|---|
|
|
| printedparts_code_prefix | 3DP | itemcode prefix |
|
|
| printedparts_default_threshold | 5 | seed for new items |
|
|
| printedparts_unknown_badge | deny | kiosk policy for unresolvable badges |
|
|
|
|
## 9. Manifest
|
|
|
|
name printedparts, version 0.1.0, api_prefix /api/printedparts,
|
|
core_version ">=0.11.0,<1.0.0", dependencies ["employees"],
|
|
default_enabled false (site opts in - USB precedent).
|
|
|
|
## 10. Explicitly out of scope (v1)
|
|
|
|
- Reservations/approvals, per-item cost, print-queue integration, multi-bin
|
|
per item, email low-stock alerts (the reports + dashboard widget cover
|
|
monitoring; alerting can ride the existing report-email endpoint later).
|
|
|
|
## 11. Risks / decisions taken
|
|
|
|
- Cached quantity vs ledger drift: single-commit writes + a reconcile query in
|
|
the stock report (flags items where cache != ledger sum).
|
|
|
|
### Decision: the kiosk take endpoint is an unauthenticated WRITE
|
|
|
|
This is the first open mutation in the product - every existing kiosk
|
|
endpoint (notifications, employees, shopfloor) is a read, and the closest
|
|
write (USB checkout) is JWT + permission gated. Accepted deliberately, on
|
|
these grounds, and any future open-write endpoint must meet the same bar:
|
|
|
|
1. Decrement-only: it can reduce stock of an active item, nothing else - no
|
|
catalog edits, no restocks, no reads it does not already expose.
|
|
2. Fully attributed: it refuses to act without a badge that resolves per the
|
|
site policy; every action lands in the ledger with SSO + name + time.
|
|
3. Bounded blast radius: worst case is stock counts driven low, which the
|
|
ledger makes visible and reversible (adjust with reason).
|
|
4. Physically rate-limited: it exists for a touch screen on the shop floor;
|
|
there is nothing to enumerate and nothing returned worth scraping.
|
|
- 1x0.5in QR marginal: default to CODE128 barcode.
|
|
- Not an Asset: no floor-map plotting or warranty for items. If a site later
|
|
wants bins on the floor map, revisit via get_map_overlays (ADR-010).
|