11 Commits

Author SHA1 Message Date
cproudlock
a8a6baf979 printedparts stage 13: pick alert recipients from shopdb users
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.13.0 puts the User model on the plugin surface. The
settings page gains a checkbox picker over the user list; selected
users receive low-stock alerts at their account email, merged and
deduped with the free-text address list, inactive accounts skipped,
site alert_recipients still the fallback when both are empty.
2026-07-17 08:30:04 -04:00
cproudlock
427eb0de8c printedparts stage 12: admin settings page + settings-rail card
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
PrintedPartsSettings edits the four plugin settings (code prefix,
default threshold, kiosk badge policy, alert recipients) through the
core settings API; the route rides the plugin's router file and the
settings shell nests it into the rail; get_settings_cards contributes
the catalog card while the plugin is enabled.
2026-07-17 08:25:33 -04:00
cproudlock
df918ed38f printedparts stage 11: low-stock email alerts on threshold crossing
Some checks failed
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Contract 0.12.0: send_email/send_alert join the plugin surface (the
mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest
pins the new floor. The alert fires inside _ledger_write only when a
decrement CROSSES the item's threshold - one alert per depletion,
rearmed by restocking above - and is best-effort after the commit so
mail trouble can never fail a take. Recipients come from
printedparts_alert_email, falling back to the site alert_recipients.
on_enable re-seeds settings idempotently so existing installs pick up
new keys. Crossing/rearm semantics proven by test.
2026-07-17 08:15:50 -04:00
cproudlock
fc0d48a6a7 printedparts stage 10: closeout - lab guide rewritten from the real build
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
The lab is now a build-along mirroring what actually happened: ten
stages, each with the goal, the divergences, a see-it-work check, and
the errors genuinely hit while building (empty Migration error from a
broken model import, the migration-guard KeyError, the missing Lucide
icon, nested-app-context test writes, Decimal sums, and the authz
sweep catching the deliberately open kiosk take). That last one gets
its explicit EXEMPT_ENDPOINTS entry with a pointer to the decision
record - the net stays, the exception is reviewable. Full suite: 993
backend tests, 49 vitest, frontend build, naming hook, all green.
2026-07-17 08:11:36 -04:00
cproudlock
b68e927ef6 printedparts stage 9: reports - stock w/ reconcile, consumption, by-person
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Three jwt-optional endpoints with ?format=csv, merged into the reports
hub via get_reports while the plugin is enabled. The stock report's
ledgerdelta column is the reconcile check: 0 for every item whose
stock moved through the ledger, nonzero for anything that bypassed it
(the hand-seeded dev rows demonstrate the catch). MySQL SUM returns
Decimal - cast to int or the delta serializes as a string.
2026-07-17 08:04:09 -04:00
cproudlock
6439d1ccd9 printedparts stage 8: 1x0.5in bin labels
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
CI / migrations-mysql (push) Failing after 7s
New public print view at /print/printedparts-labels following the
plugin-owned USB label precedent: multi-select with per-item copies,
CODE128 of the item code via JsBarcode (a QR at this size is at the
edge of scanner tolerance), one label per page on 1in x 0.5in roll
stock via a new @page size. The Detail page's Bin Label button
preselects its item through ?item=<id>; the list header gains a batch
Print Labels button.
2026-07-17 08:00:35 -04:00
cproudlock
6ed3da1b64 printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take
Some checks failed
CI / backend (push) Failing after 1m43s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Two open endpoints: an item lookup by scanned code and the take POST -
the product's first unauthenticated write, held to the decision
record's bar (decrement-only, badge-attributed server-side, bounded,
physically rate-limited; justification in the plugin README). The
/parts-kiosk route is a full-screen no-auth view beside /shopfloor: a
hidden always-focused input consumes keyboard-wedge scans for
whichever step is active, TouchKeypad (net-new 3x4 grid) takes the
quantity, and a success screen resets after a few seconds. Manual
type-in fallbacks cover damaged labels. Kiosk test proves open access,
the over-take guard, the badge policy, and cache==ledger afterward.
2026-07-17 07:49:13 -04:00
cproudlock
d6a78a72ff printedparts stage 6: RBAC - declared permissions gate every mutation
Some checks failed
CI / backend (push) Successful in 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
get_permissions declares view/create/edit/delete/restock (seeded on
install/enable and by flask seed permissions); every write route adds
require_permission on top of jwt_required. New test proves
authentication alone is not authorization: a role-less member gets
403 where an admin succeeds.
2026-07-17 07:42:07 -04:00
cproudlock
6dfc8906c4 printedparts stage 5: the ledger - restock/adjust with badge attribution
Some checks failed
CI / backend (push) Failing after 1m42s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
Badge resolver copied from the USB contract (SSO digits, 0<digits>BZ
PayNo wrap) with names from the employees directory and the
unknown-badge policy setting; deliberately copied rather than
cross-imported so the contract test stays green. Restock and adjust
write the ledger row and move the cached quantity in one commit -
the single-commit invariant every write path must use. Adjust
requires a reason and refuses to drive stock below zero. Detail page
gains Restock/Adjust modals. Seven tests cover minting, the
cache==ledger invariant, badge shapes, policy toggle, and auth.
2026-07-17 07:41:18 -04:00
cproudlock
cb367a38f9 printedparts stage 4: catalog mutations, item photos, detail + form
Some checks failed
CI / backend (push) Failing after 1m40s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
POST/PUT/DELETE for items: create mints the itemcode from the
configured prefix plus the flushed row id, update refuses
quantityonhand (ledger-managed - restock/adjust arrive next stage),
delete soft-retires. The image upload/serve/delete trio replicates the
models.py pattern into instance/printedpartsimages/ with a public GET.
PrintedItemDetail follows the unified detail skeleton (hero photo,
info list, transaction history table); PrintedItemForm covers
create/edit plus photo management on edit.
2026-07-17 07:36:54 -04:00
cproudlock
d1c844d533 printedparts stage 3: read API + list page (first visible win)
Some checks failed
CI / backend (push) Successful in 1m39s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s
GET /items (paginated, search across code/name/description/bin,
lowstock filter) and GET /items/<id> with recent transactions, both
open reads. printedpartsApi client, router file repointed at the
renamed views, PrintedItemsList with image thumbs and a red/green
quantity badge against the per-item threshold. Nav entry '3D Parts'
with a new 'box' Lucide icon mapping (the sidebar renders nothing for
unknown icon names - lab gotcha).
2026-07-16 17:10:42 -04:00
28 changed files with 2460 additions and 722 deletions

View File

@@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state
- 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- `__contract_version__` at 0.13.0 (0.12.0 added the mailer, 0.13.0 the User model, to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
- 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
- Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM).

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.11.0'
__contract_version__ = '0.13.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -479,6 +479,11 @@ What `shopdb.api` exposes:
- Import mode: `apply_import_timestamps`, `import_mode_active`,
`parse_import_datetime`
- Legacy employee directory: `employee_connection`
- `User` (0.13.0) - the account model, e.g. resolving alert recipients'
emails from selected user ids
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients
```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query

View File

@@ -1,222 +1,340 @@
# Plugin lab: build the printedparts plugin yourself
# Plugin lab: build the printedparts plugin
A guided, milestone-based exercise: build the 3D-printed-parts storefront +
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`. Each
milestone lists what to build, which existing code to imitate, and a
checkpoint that proves you are done. Read the spec first, keep it open.
Prerequisites: a working dev environment (README quick start), the three
plugin docs skimmed once - `PLUGIN-QUICKSTART.md` (mechanics),
`PLUGIN-GUIDE.md` (the measuringtools walkthrough - your narrative reference),
`PLUGIN-HOOKS.md` (hook reference). Naming rules: `CONTRIBUTING.md` - the
pre-commit hook enforces them, read it before naming anything.
Ground rules
- Import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`). The contract
test fails your build otherwise.
- DB columns: lowercase concatenated (`quantityonhand`, not quantity_on_hand).
- Run `bash scripts/check-naming-and-style.sh` and the test suite at every
checkpoint.
- Commit once per milestone. Working on a branch is fine; so is a fork.
A hand-held, build-along tutorial: construct the 3D-printed-parts storefront +
kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by
stage, seeing each piece work before moving on. Written for someone building
their first plugin. The finished implementation lives on the
`feat/printedparts-plugin` branch with one commit per stage, tagged
`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03
lab-stage-04` shows exactly what a stage changes.
Know before you start
- You are building a BUNDLED plugin inside this repo. Plugin frontend files
live in core (`frontend/src/...`), and three core files get small edits:
`frontend/src/api/index.js` (api client), the router, and
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is
normal for all 12 bundled plugins - external-plugin UI packaging does not
exist yet - so do not be confused when a "plugin" touches core.
- This plugin deliberately DIVERGES from the scaffold in three places, each
a teaching point you will hit in order: (1) it is NOT an asset type, so the
scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL
baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk
take endpoint is the product's first UNauthenticated write - read the
decision record in the proposal before building it (M4).
- Instructor option: keep a solution branch with one commit per milestone
(tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to
see exactly what a milestone changes.
`frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and
`PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for
all bundled plugins - external-plugin UI packaging does not exist yet.
- Three deliberate divergences from the scaffold, each a teaching point:
(1) NO AssetType - these are quantity consumables, not ADR-001 assets
(stage 1); (2) the migration is a REAL baseline that creates tables, not a
stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's
first UNauthenticated write - read the decision record in the proposal
before stage 7.
- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
DB names lowercase concatenated (`quantityonhand`); run
`bash scripts/check-naming-and-style.sh` + the tests at every stage; one
git commit per stage.
Prerequisites: working dev environment (README quick start), skim
`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this
lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules.
---
## Milestone 1 - skeleton, models, migration (backend exists)
## Stage 0 - orientation (no code)
Build
1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`.
2. The scaffold assumes an Asset-extension plugin; ours is standalone.
In `plugin.py` strip the AssetType seeding from `on_install` (imitate
`plugins/knowledgebase/plugin.py` instead of the template).
3. Replace the scaffold model with the two spec tables: `PrintedItem`,
`PrintedItemTransaction` (`models/printeditem.py`). Use `BaseModel` +
`AuditMixin` from `shopdb.api`. Itemcode: leave generation to the API
layer (M2), column just `unique=True, index=True`.
4. Register both tables in `PLUGIN_TABLE_OWNERS`
(`shopdb/plugins/alembic_template.py`).
5. Create `plugins/printedparts/migrations/` with the 3-line `env.py` +
`script.py.mako` (copy from measuringtools) and a REAL baseline
`versions/0001_printedparts_baseline.py` - hand-written
`op.create_table(...)` for both tables (see
`plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py`).
6. Manifest: api_prefix `/api/printedparts`, `dependencies: ["employees"]`,
`default_enabled: false`.
Read the proposal. Tour the two reference plugins you will imitate:
`plugins/usb/` (checkout ledger + badge contract) and
`plugins/measuringtools/` (post-cutover migration baseline, hooks).
See it work: run the app, log in.
Checkpoint
## Stage 1 - scaffold, minus the AssetType
```
flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout"
```
Walk the generated tree. Then diverge:
1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its
`on_install` call - a printed part is a kind-with-a-count, not an asset.
Replace it with settings seeding (see the tagged commit): three Setting
rows, category `printedparts` - `printedparts_code_prefix` (3DP),
`printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny).
2. `manifest.json`: `"dependencies": ["employees"]` (badge names),
`"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`,
`"display_name": "3D Printed Parts"`.
See it work: `flask plugin list` shows printedparts [Available].
Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec`
## Stage 2 - models + real migration baseline + tables live
1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem`
(itemcode unique+indexed, itemname, itemdescription, imageurl,
quantityonhand, lowstockthreshold, binlocation, printnotes) and
`PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype
take/restock/adjust, SIGNED quantitychange, employeesso, employeename,
reason, transactiondate) - both on `BaseModel`. The ledger is the source
of truth; quantityonhand is a cache moved in the same commit.
2. Update `models/__init__.py` exports and `plugin.py` `get_models`.
3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`):
`'printedparts': ('printeditems', 'printeditemtransactions'),`
4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from
measuringtools (change PLUGIN_NAME), then hand-write
`versions/0001_printedparts_baseline.py` with explicit `op.create_table`
for both tables + the three transaction indexes.
5. The scaffold's `api/routes.py` still imports the model you deleted - make
the blueprint import cleanly (a placeholder route is fine for now).
See it work:
```
flask plugin install printedparts && flask plugin enable printedparts
flask plugin upgrade-all # applies your 0001
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SELECT * FROM alembic_version_printedparts; -- your revision id
pytest tests/ -q # nothing broken, contract tests green
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
flask plugin upgrade-all -- printedparts: ok (idempotent)
```
## Milestone 2 - CRUD API + permissions + itemcode
Common errors (both hit for real while building this):
- An empty `Migration error:` on install. Root cause: anything that makes
`plugins.printedparts.models` fail to import - the alembic env imports the
models package, which pulls in plugin.py and routes.py. Here it was the
scaffold routes importing the deleted model; the ImportError gets caught
and retried down a subprocess path with no stderr. Fix the import, not the
migration.
- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add
`EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` -
the guard makes every new plugin declare its expected head on purpose.
Build
1. `api/routes.py`: list/detail/create/update/soft-delete per the spec table.
Imitate a clean plugin blueprint (`plugins/measuringtools/api/routes.py`)
for pagination (`perpage`, `dir`), search, and the shared
`success_response`/`error_response` helpers from `shopdb.api`.
2. Itemcode on create: `<prefix>-<id zero-padded to 4>`; prefix from Setting
`printedparts_code_prefix` (read via `self.get_setting` or Setting model
through `shopdb.api`). Two-step: insert, flush to get the id, set code.
3. `get_permissions()` on the plugin class: view/create/edit/delete/restock
(tuples, category `printedparts` - copy shape from
`plugins/usb/plugin.py`). Gate mutations with `@jwt_required()` +
`@require_permission(...)`; reads are `@jwt_required(optional=True)`.
4. Restock + adjust endpoints: both write a LEDGER row and move
`quantityonhand` in the same commit. Adjust requires `reason`, rejects a
result below zero. Both record the operator: accept `badge` in the body
and resolve it (M4 extracts the resolver - for now digits=SSO is enough).
5. Seed the 3 settings in `on_install` (Setting.set pattern - see how other
plugins seed in `on_install`; category `printedparts`).
Commit + tag `lab-stage-02`.
Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead
and wire just the api client + router entry + a bare `PrintedItemsList.vue`
(first two steps of M3), seed two rows by hand, and look at your parts in the
browser. Everything before that moment is invisible; seeing the table makes
the rest of the lab concrete. Then come back and finish the mutations here.
## Stage 3 - read API + list page (the first visible win)
Checkpoint
```
pytest plugins/printedparts/tests/ -q # write tests as you go:
# - create mints 3DP-0001 style codes
# - restock/adjust move both ledger and cache atomically
# - adjust below zero -> 400/422
# - permission gates: anonymous create -> 401, wrong-perm user -> 403
curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK
```
1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via
`get_pagination_params`/`paginate_query`, search across
code/name/description/bin, `?lowstock=true` filter) and
`GET /items/<id>` returning the item + its 25 most recent transactions.
2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box',
'route': '/printedparts', 'position': 46}`.
3. Frontend: paste the `printedpartsApi` client into
`frontend/src/api/index.js` (list/get for now, paths under
`/printedparts/items`); rename the scaffold views to
`PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint
`router/routes/printedparts.js`; build the list page from
`PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an
image thumb column and a red/green quantity badge vs the threshold.
4. Seed two or three rows by hand (SQL or flask shell) purely to have
something to look at. NOTE: hand-seeded stock has no ledger backing - the
stage-9 reconcile report will flag exactly these rows, which is the check
working.
## Milestone 3 - management frontend + images
See it work: navigate to `/printedparts` - your parts in a table, low-stock
row red-badged. Everything before this moment was invisible; from here on
every stage shows on screen.
Build
1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters
and a router file; rename/build them into `PrintedItemsList/Detail/Form`
per the spec. Master templates: `PrintersList.vue` (list),
`PrinterDetail.vue` (detail). Global CSS only; CSS variables for colors
(frontend/CLAUDE.md rules).
2. Register the API client in `frontend/src/api/index.js` (paste the
generated `frontend-api-snippet.js`, extend with restock/adjust/image
calls).
3. Low-stock highlighting on the list (`quantityonhand <= lowstockthreshold`
-> danger badge). Filters: search + low-stock-only checkbox.
4. Image upload: replicate the models-image trio - upload/serve/delete -
from `shopdb/core/api/models.py` INTO the plugin blueprint
(`instance/printedpartsimages/`, public GET serve, imageurl column,
prefix-guarded delete). Wire the Form upload + Detail hero image.
5. Nav: `get_navigation_items()` -> "3D Parts" (usb plugin shape). Router
meta: list/detail plugin-gated, new/edit `requiresAuth` (see
`frontend/src/router/routes/usb.js`).
Common error: nav icon missing. The sidebar maps icon NAMES to Lucide
components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing.
Add `'box': Box` to the map (and the import) or reuse an existing name.
Checkpoint: create an item with a photo in the UI; thumbnail on list, hero on
detail; restock from detail updates qty + shows in history; frontend build +
`npx vitest run` green; naming hook green.
Commit + tag `lab-stage-03`.
## Milestone 4 - badge resolution + kiosk
## Stage 4 - catalog mutations + item photos + detail/form pages
Build
1. Badge resolver in the plugin (`services/badges.py`): copy the USB contract
- all-digits -> SSO; `^0(\d+)BZ$` case-insensitive -> PayNo; resolve
display name via the employees plugin directory the way
`plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside
the function, degrade gracefully when the plugin is absent). Policy
setting `printedparts_unknown_badge` (deny -> 422).
2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read
precedent): `GET /kiosk/item/<itemcode>` and `POST /kiosk/take`
{itemcode, badge, quantity}. Take: validate active item, 1 <= qty <=
onhand, resolve badge, single-commit ledger row + decrement. Clear error
strings - the kiosk displays them verbatim.
3. Kiosk view `/parts-kiosk`: top-level route, NO requiresAuth, outside
AppLayout (register beside `/shopfloor` in `frontend/src/router/index.js`).
Three-step flow per the spec. The scanner is a keyboard wedge: hidden
always-focused input, submit on Enter, route the scan to whichever step is
active. Build `TouchKeypad.vue` (3x4 grid of big buttons, emits digits/
clear/backspace) - net-new, nothing to copy, keep it dumb.
4. Manual fallback path (typed item search + badge entry) behind a small
"no scanner?" link.
1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the
id: `<prefix>-<id:04d>` with the prefix from Setting. `PUT /items/<id>`
updates catalog fields but REFUSES `quantityonhand` (ledger-managed).
`DELETE` soft-retires. All `@jwt_required()` (permissions come in
stage 6).
2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE
`/items/<id>/image` + public `GET /image/<filename>`, storing
`printeditem-<id>.<ext>` in `instance/printedpartsimages/`, wiping prior
extensions on replace, prefix-guarded delete.
3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info
list, transactions table); `PrintedItemForm.vue` create/edit + photo
upload on edit; extend the api client.
Checkpoint: full kiosk walkthrough on a touchscreen (or browser): scan/type
an itemcode -> item card; badge `0123456BZ` and plain SSO both resolve; take 3
-> success screen, qty down 3, ledger row has your name; taking more than
onhand -> friendly error; unknown badge -> denied message. Backend tests for
the resolver shapes + take validation.
See it work: add a part with a photo in the UI; thumbnail on the list, hero
on the detail; `PUT` with `quantityonhand` returns the ledger-managed error.
## Milestone 5 - labels (1in x 0.5in)
Commit + tag `lab-stage-04`.
Build
1. Public print route `/print/printedparts-labels` + view (imitate
`USBLabelBatch.vue` - USB is the precedent for a plugin OWNING its label
page instead of joining TYPE_CONFIG).
2. New stock size: `@page { size: 1in 0.5in; margin: 0 }`, one label per page
(roll-fed label printers treat each page as one label). Layout: CODE128
via JsBarcode (~0.9in wide, displayValue false), itemcode text ~7pt under
it, optional truncated name. Offer QR as a variant but default barcode.
3. Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid
sheet fallback (mini72 pattern in `AssetLabelBatch.vue`).
4. Print buttons on Detail (single) and List (batch selected).
## Stage 5 - the ledger: restock/adjust with badge attribution
Checkpoint: print preview shows one 1x0.5 label per page; a printed (or
PDF-zoomed) barcode scans back into the kiosk and pulls up the right item.
That round trip - label printed from the catalog, scanned at the kiosk,
stock decremented with your name on it - is the demo moment; make it work
end to end before polishing.
1. `services/badges.py` - COPY the USB badge contract (do not import
`plugins.usb`; cross-plugin imports fail the contract test):
`^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees
plugin `DirectoryEmployee` (lazy import, graceful fallback), and the
`printedparts_unknown_badge` policy - deny raises a kiosk-displayable
`BadgeError`, allow records the SSO with an empty name.
2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant:
append the transaction row and move the cached quantity in ONE commit.
Every write path goes through it.
3. `POST /items/<id>/restock` {quantity, badge} and `/adjust`
{quantitychange, reason, badge}; adjust requires a reason and refuses to
drive stock below zero.
4. Detail page: Restock/Adjust modals (shared `Modal.vue`).
5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge
shape, reason-required + below-zero guards, the policy toggle, 401 for
anonymous. See `tests/test_plugins/test_printedparts_ledger.py`.
## Milestone 6 - metrics, reports, widget
See it work: restock from the detail page with your SSO - quantity moves AND
a named transaction row appears.
Build
1. `get_reports()` -> stock, consumption (date range), by-person; endpoints
in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via
the `generate_csv` helper pattern (`shopdb/core/api/reports.py` shows the
shape; a plugin report lives in the plugin and is merged into
`GET /api/reports` automatically when enabled).
2. Stock report includes the reconcile check: flag rows where cached
`quantityonhand` != SUM(ledger). Should always be empty; if not, you have
a non-atomic write path - find it.
3. OPTIONAL/deferred: `get_dashboard_widgets()` -> low-stock count. Caveat:
this hook predates the ADR-010 data-only renderers - the widget names a
frontend component that must already exist in core, so a plugin widget
only renders if you also add that component. Reports are the primary
monitoring surface; skip the widget unless you want the extra credit.
4. Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks +
weeks-to-empty). Plain SQL over the ledger.
Common error: in tests, mutating rows through a nested `app.app_context()`
does not reliably stick in the sqlite test env - stock the item through the
real restock endpoint instead (also more honest).
Checkpoint: reports appear on /reports grouped under the plugin, CSV
downloads; widget renders on the dashboard; reconcile column all-clear after
a kiosk session.
Commit + tag `lab-stage-05`.
## Milestone 7 - lifecycle + closeout
## Stage 6 - RBAC
Build/verify
1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry,
routes, reports, and grantable permissions all disappear; enable restores.
2. Fresh-database proof: point DATABASE_URL at a scratch DB, `flask db
upgrade` + `flask plugin install/enable/upgrade-all` - everything works
with zero manual SQL.
1. `get_permissions` on the plugin: view/create/edit/delete/restock, category
`printedparts` (seeded automatically on install/enable and by
`flask seed permissions`).
2. Add `@require_permission('printedparts.<x>')` under `@jwt_required()` on
every mutation: create/edit/delete/image = create/edit/delete; restock +
adjust = restock.
3. Test with the `member_headers` fixture (authenticated, role-less): 403
where admin succeeds - authentication alone is not authorization.
See it work: the permissions appear in the role grid (Settings > Roles), and
the member test passes.
Commit + tag `lab-stage-06`.
## Stage 7 - the kiosk (the deliberate open write)
Read the decision record in the proposal first. The take endpoint must stay:
decrement-only, badge-attributed server-side, bounded, physically
rate-limited. Put the justification in the plugin README.
1. Backend, both UNdecorated: `GET /kiosk/item/<itemcode>` (summary for a
scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} -
validate active item, 1 <= qty <= onhand, resolve the badge, then
`_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown
verbatim on the kiosk - write them for a person standing at a screen.
2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace.
3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside
`/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout,
`meta.plugin` so a disabled plugin dead-ends). Three steps - scan item,
scan badge, keypad quantity - driven by ONE hidden always-focused input
that consumes keyboard-wedge scans (scanners type the code + Enter) for
whichever step is active; manual type-in fallbacks for damaged labels.
Success screen auto-resets after a few seconds.
4. Kiosk test: open access, over-take guard, unknown-badge 422, and
cache==ledger afterward.
See it work: full walkthrough in a browser - type a code, badge in, keypad 2,
TAKE - stock drops with your name in the ledger.
Common error (by design): the full suite fails with
`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`.
That sweep asserts EVERY mutating route rejects a role-less user - the
framework's net against accidentally-open writes. Your kiosk take is open on
purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment
pointing at the decision record. The net stays; the exception is explicit
and reviewable.
Commit + tag `lab-stage-07`.
## Stage 8 - 1in x 0.5in bin labels
1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public
`/print/printedparts-labels` route beside `/print/usb-labels` (a plugin
OWNS its label page - the USB precedent; parts are not in the asset-label
TYPE_CONFIG because they are not assets).
2. The label: CODE128 of the itemcode via JsBarcode
(`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`)
+ the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner
tolerance; CODE128 of `3DP-0042` is comfortable.
3. Roll stock = one label per page: a global (unscoped) print style with
`@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on
each `.bin-label`. Multi-select + per-item copies; `?item=<id>`
preselects (the Detail page's Bin Label button).
See it work: print preview shows one 1x0.5 label per page; scan the printed
barcode (or the on-screen one with a phone scanner app) into the kiosk -
label -> scan -> badge -> take -> ledger is the demo moment.
Commit + tag `lab-stage-08`.
## Stage 9 - reports + the reconcile check
1. Three jwt-optional endpoints in the plugin blueprint, each honoring
`?format=csv` (local CSV helper - `generate_csv` is not on the contract
surface): `/reports/stock`, `/reports/consumption?days=N`,
`/reports/by-person?days=N`.
2. The stock report's `ledgerdelta` column = cached quantityonhand minus the
ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a
write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows
show up here, proving the check works.
3. `get_reports` on the plugin (endpoint-style entries, categories
inventory/usage) - they merge into `GET /api/reports` and the /reports hub
while the plugin is enabled.
Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries
strings.
Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only
renderers; needs a core component) and a Settings card (needs a settings page
to link). Reports are the monitoring surface.
Commit + tag `lab-stage-09`.
## Stage 10 - closeout
1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and
grantable permissions disappear; API routes only disappear after a
RESTART (blueprints register at startup - the guide's section 12 gotcha).
Re-enable.
2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` +
`flask plugin install/enable printedparts` + `upgrade-all` - green with
zero manual SQL.
3. Full suite: backend pytest, vitest, frontend build, naming hook.
4. End checklist from `PLUGIN-GUIDE.md` section 12.
4. Walk `PLUGIN-GUIDE.md` section 12's End checklist.
Done means: a colleague can clone the repo, enable the plugin, print a bin
label, and take a part at the kiosk with their badge - without asking you
anything.
## Stage 11 (extension) - low-stock email alerts
Per-item thresholds already exist; alerting on them is a worked example of a
CONTRACT ADDITION, because the mailer was not on the plugin surface:
1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump
`__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the
docs-drift guard test fails until the doc's version example matches.
Manifest pins `core_version >=0.12.0` since the plugin now needs it.
2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the
threshold (before > threshold >= after). Crossing, not being-below, is the
natural debounce: one alert per depletion, restocking above rearms.
Best-effort try/except AFTER the commit - mail failure must never fail
the take.
3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty
falls back to the site's alert_recipients via `send_alert`. Seed the new
setting in on_enable too (idempotent) so already-installed sites get it.
4. Test with a monkeypatched sender: no alert above threshold, one on the
crossing, no re-fire while below, rearm after restock (see
`test_lowstock_alert_fires_on_crossing_only`).
## Stage 12 (extension) - the admin settings page
A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred
it. The page is ordinary:
1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four
keys via `settingsApi.list({category: 'printedparts'})`, save each with
`settingsApi.update(key, value)` (admin-gated server-side).
2. Route in the PLUGIN's router file with path `settings/printedparts` +
`requiresAuth, requiresAdmin, plugin` meta - the router shell
automatically nests any `settings/...` path under the two-pane settings
rail.
3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` -
the card appears in the rail's catalog while the plugin is enabled.
## Stage 13 (extension) - alert recipients picked from shopdb users
Free-text emails rot; user accounts do not. Another contract addition:
`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the
docs-drift guard again).
1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded
beside the others.
2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's
account email, merge with the free-text list, dedupe order-preserving;
empty result still falls back to the site alert_recipients.
3. Settings page: checkbox picker over `usersApi.list()` (the page is
admin-only, matching the endpoint), saving joined ids.
4. Test: active user's email + free-text merge deduped, inactive user
skipped (`test_alert_recipients_merge_users_and_freetext`).
---
## Where each pattern lives (cheat sheet)
@@ -233,5 +351,6 @@ anything.
| Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` |
| Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` |
| List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` |
| Reports hook + CSV | `plugins/warranty/` report + `shopdb/core/api/reports.py` |
| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` |
| Permissions declaration | `plugins/usb/plugin.py::get_permissions` |
| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` |

View File

@@ -1,6 +1,6 @@
# Roadmap
shopdb-flask is at `__contract_version__ = '0.11.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
## Phase status

View File

@@ -1126,3 +1126,44 @@ export const measuringtoolsApi = {
}
}
}
// 3D printed parts (printedparts plugin)
export const printedpartsApi = {
list(params = {}) {
return api.get('/printedparts/items', { params })
},
get(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}`)
},
create(data) {
return api.post('/printedparts/items', data)
},
update(printeditemid, data) {
return api.put(`/printedparts/items/${printeditemid}`, data)
},
remove(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}`)
},
uploadImage(printeditemid, file) {
const formData = new FormData()
formData.append('file', file)
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
deleteImage(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}/image`)
},
restock(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
},
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)
}
}

View File

@@ -0,0 +1,38 @@
<template>
<div class="touch-keypad">
<button v-for="digit in digits" :key="digit" type="button"
class="keypad-button" @click="$emit('digit', digit)">
{{ digit }}
</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('clear')">C</button>
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
<button type="button" class="keypad-button keypad-muted"
@click="$emit('backspace')">&lt;</button>
</div>
</template>
<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>
<style scoped>
.touch-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
max-width: 20rem;
}
.keypad-button {
font-size: 1.8rem;
padding: 1rem 0;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
}
.keypad-button:active { background: var(--primary); color: #fff; }
.keypad-muted { color: var(--text-light); }
</style>

View File

@@ -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',
@@ -109,6 +117,12 @@ const routes = [
component: () => import('../views/print/USBLabelBatch.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
meta: { plugin: 'printedparts' }
},
{
path: '/',
component: AppLayout,

View File

@@ -11,25 +11,31 @@ export default [
{
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/new',
name: 'printedparts-new',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
meta: { plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',
name: 'printedparts-edit',
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'settings/printedparts',
name: 'settings-printedparts',
component: () => import('../../views/settings/PrintedPartsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
}
]

View File

@@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
KeyRound, LogOut
Box, KeyRound, LogOut
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
@@ -147,6 +147,7 @@ const iconMap = {
'image': Image,
'shield': ShieldCheck,
'ruler': Ruler,
'box': Box,
}
// Default navigation (used as fallback if API fails)

View File

@@ -0,0 +1,207 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
<p>
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
the item code, scannable at the parts kiosk.
</p>
<div v-if="loading" class="loading-msg">Loading parts...</div>
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
<div v-else class="parts-grid">
<div
v-for="item in items"
:key="item.printeditemid"
class="part-item"
:class="{ selected: isSelected(item) }"
@click="toggleItem(item)"
>
<input type="checkbox" :checked="isSelected(item)" @click.stop />
<label>
<strong><code>{{ item.itemcode }}</code></strong>
<div class="alias">{{ item.itemname }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedItems.length }}</span> labels
<label class="copies-label">Copies each:
<input v-model.number="copies" type="number" min="1" max="10" />
</label>
</div>
<button class="print-btn" :disabled="selectedItems.length === 0"
@click="print">Print Labels</button>
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
</div>
</div>
<div class="labels-container">
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
<div class="bin-code">{{ label.itemcode }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import JsBarcode from 'jsbarcode'
import { printedpartsApi } from '../../api'
const items = ref([])
const selectedItems = ref([])
const copies = ref(1)
const loading = ref(true)
const barcodeElements = ref({})
onMounted(async () => {
try {
const response = await printedpartsApi.list({ perpage: 500 })
items.value = response.data.data || []
// ?item=<id> preselects one part (the Detail-page print button)
const preselect = new URLSearchParams(window.location.search).get('item')
if (preselect) {
const match = items.value.find(
candidate => String(candidate.printeditemid) === preselect)
if (match) selectedItems.value = [match]
}
} catch (error) {
console.error('Error loading parts:', error)
} finally {
loading.value = false
}
})
const printLabels = computed(() => {
const labels = []
for (const item of selectedItems.value) {
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
labels.push(item)
}
}
return labels
})
function isSelected(item) {
return selectedItems.value.some(
candidate => candidate.printeditemid === item.printeditemid)
}
function toggleItem(item) {
if (isSelected(item)) {
selectedItems.value = selectedItems.value.filter(
candidate => candidate.printeditemid !== item.printeditemid)
} else {
selectedItems.value = [...selectedItems.value, item]
}
}
function setBarcodeElement(element, index) {
if (element) barcodeElements.value[index] = element
}
watch(printLabels, async labels => {
await nextTick()
labels.forEach((label, index) => {
const element = barcodeElements.value[index]
if (element) {
// CODE128 of the short item code fits 1x0.5in with comfortable
// scanner tolerance; a QR at this size would be marginal.
JsBarcode(element, label.itemcode, {
format: 'CODE128',
displayValue: false,
width: 1.4,
height: 26,
margin: 0
})
}
})
}, { deep: true })
function print() {
window.print()
}
</script>
<style scoped>
.controls {
max-width: 46rem;
margin: 1rem auto;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.5rem;
max-height: 20rem;
overflow-y: auto;
margin: 1rem 0;
}
.part-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.35rem;
cursor: pointer;
}
.part-item.selected { border-color: var(--primary); }
.alias { color: var(--text-light); font-size: 0.85rem; }
.selected-count { margin: 0.75rem 0; }
.copies-label { margin-left: 1.25rem; }
.copies-label input { width: 4rem; padding: 0.25rem; }
.print-btn, .clear-btn, .select-all-btn {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
}
.loading-msg { color: var(--text-light); padding: 1rem; }
/* screen preview of the labels */
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
.bin-label {
width: 1in;
height: 0.5in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fff;
outline: 1px dashed #bbb;
}
.bin-barcode { width: 0.92in; height: 0.3in; }
.bin-code {
font-size: 6.5pt;
font-family: monospace;
color: #000;
line-height: 1;
}
/* 1in x 0.5in roll stock: one label per page */
@media print {
.no-print { display: none; }
.labels-container { display: block; padding: 0; gap: 0; }
.bin-label {
outline: none;
page-break-after: always;
break-after: page;
}
}
</style>
<style>
@media print {
@page { size: 1in 0.5in; margin: 0; }
body { margin: 0; }
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="parts-kiosk" @click="focusWedge">
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
always-focused input; whichever step is active consumes the scan -->
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
<header class="kiosk-header">
<h1>3D Printed Parts</h1>
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
Start over
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!-- step 1: scan the bin -->
<section v-if="step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<p class="kiosk-hint">
No scanner?
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
</p>
<div v-if="manualEntry" class="manual-row">
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
@keydown.enter="lookupItem(manualCode)" />
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
</div>
</section>
<!-- step 2: badge -->
<section v-else-if="step === 'badge'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">Scan your badge</p>
<div class="manual-row">
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
@keydown.enter="acceptBadge(manualBadge)" />
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
</div>
</section>
<!-- step 3: quantity -->
<section v-else-if="step === 'quantity'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="quantity-display">{{ quantity || '0' }}</div>
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-success">Done - {{ doneMessage }}</p>
<p class="kiosk-hint">Starting over in a few seconds...</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import TouchKeypad from '../../components/TouchKeypad.vue'
const step = ref('item')
const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
const doneMessage = ref('')
const submitting = ref(false)
const manualEntry = ref(false)
const manualCode = ref('')
const manualBadge = ref('')
const wedgeInput = ref(null)
const wedgeBuffer = ref('')
let resetTimer = null
onMounted(focusWedge)
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusWedge() {
wedgeInput.value?.focus()
}
function onWedgeEnter() {
const scanned = wedgeBuffer.value.trim()
wedgeBuffer.value = ''
if (!scanned) return
if (step.value === 'item') lookupItem(scanned)
else if (step.value === 'badge') acceptBadge(scanned)
}
async function lookupItem(itemcode) {
error.value = ''
if (!itemcode) return
try {
const response = await printedpartsApi.kioskItem(itemcode.trim())
item.value = response.data.data
step.value = 'badge'
manualEntry.value = false
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
}
focusWedge()
}
function acceptBadge(value) {
error.value = ''
const scanned = (value || '').trim()
if (!scanned) return
badge.value = scanned
manualBadge.value = ''
step.value = 'quantity'
focusWedge()
}
async function submitTake() {
submitting.value = true
error.value = ''
try {
const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode,
badge: badge.value,
quantity: parseInt(quantity.value, 10)
})
doneMessage.value = response.data.message
step.value = 'done'
resetTimer = setTimeout(reset, 4000)
} catch (takeError) {
error.value = takeError.response?.data?.data?.error?.message ||
'Could not complete - see the parts team'
if (takeError.response?.status === 422) {
// badge problem: go back a step so the next scan retries cleanly
step.value = 'badge'
}
} finally {
submitting.value = false
}
}
function reset() {
clearTimeout(resetTimer)
step.value = 'item'
item.value = null
badge.value = ''
quantity.value = ''
error.value = ''
doneMessage.value = ''
focusWedge()
}
</script>
<style scoped>
.parts-kiosk {
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.wedge-input {
position: absolute;
opacity: 0;
height: 1px;
width: 1px;
}
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
.kiosk-hint { color: var(--text-light); }
.kiosk-error {
background: var(--danger);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
}
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
.item-card {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.5rem;
width: 100%;
}
.item-photo {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: 0.4rem;
}
.quantity-display {
font-size: 3rem;
font-weight: 700;
min-width: 8rem;
text-align: center;
border-bottom: 3px solid var(--primary);
}
.take-button {
font-size: 1.5rem;
padding: 0.9rem 3.5rem;
}
.manual-row { display: flex; gap: 0.6rem; }
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="detail-page">
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="item">
<div class="hero-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
:alt="item.itemname" class="hero-image" />
<div class="hero-content">
<h2 class="hero-title">{{ item.itemname }}</h2>
<div class="hero-meta">
<span class="badge badge-secondary">{{ item.itemcode }}</span>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }} on hand
</span>
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
</div>
<div class="hero-details">
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
</div>
<div class="hero-actions">
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
Restock
</button>
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
Adjust
</button>
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
class="btn btn-secondary btn-sm">Edit</router-link>
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
class="btn btn-secondary btn-sm">Bin Label</router-link>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Details</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Item code</span>
<span class="info-value">{{ item.itemcode }}</span>
</div>
<div class="info-row">
<span class="info-label">Bin location</span>
<span class="info-value">{{ item.binlocation || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Quantity on hand</span>
<span class="info-value">{{ item.quantityonhand }}</span>
</div>
<div class="info-row">
<span class="info-label">Low-stock threshold</span>
<span class="info-value">{{ item.lowstockthreshold }}</span>
</div>
<div class="info-row" v-if="item.printnotes">
<span class="info-label">Print notes</span>
<span class="info-value">{{ item.printnotes }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Recent transactions</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Qty</th>
<th>Who</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr v-for="transaction in item.recenttransactions"
:key="transaction.transactionid">
<td>{{ formatDate(transaction.transactiondate) }}</td>
<td>{{ transaction.transactiontype }}</td>
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
</td>
<td>{{ transaction.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td>
</tr>
<tr v-if="!item.recenttransactions?.length">
<td colspan="5" class="empty-state">No transactions yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="audit-footer">
Created {{ formatDate(item.createddate) }} -
Modified {{ formatDate(item.modifieddate) }}
</div>
</template>
<div v-else class="card">Item not found</div>
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
<div class="form-group">
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
</div>
<div v-if="ledgerMode === 'adjust'" class="form-group">
<label>Reason *</label>
<input v-model="ledgerReason" type="text" class="form-control"
placeholder="e.g., damaged parts scrapped, recount" />
</div>
<div class="form-group">
<label>Your badge / SSO *</label>
<input v-model="ledgerBadge" type="text" class="form-control"
placeholder="Scan badge or type SSO" />
</div>
<template #footer>
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
</button>
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
import Modal from '../../components/Modal.vue'
const route = useRoute()
const item = ref(null)
const loading = ref(true)
onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
} catch (loadError) {
console.error('Error loading printed item:', loadError)
} finally {
loading.value = false
}
})
const ledgerOpen = ref(false)
const ledgerMode = ref('restock')
const ledgerQuantity = ref(null)
const ledgerReason = ref('')
const ledgerBadge = ref('')
const ledgerSaving = ref(false)
const ledgerError = ref('')
function openLedger(mode) {
ledgerMode.value = mode
ledgerQuantity.value = null
ledgerReason.value = ''
ledgerBadge.value = ''
ledgerError.value = ''
ledgerOpen.value = true
}
async function submitLedger() {
ledgerSaving.value = true
ledgerError.value = ''
try {
if (ledgerMode.value === 'restock') {
await printedpartsApi.restock(item.value.printeditemid, {
quantity: ledgerQuantity.value, badge: ledgerBadge.value
})
} else {
await printedpartsApi.adjust(item.value.printeditemid, {
quantitychange: ledgerQuantity.value,
reason: ledgerReason.value,
badge: ledgerBadge.value
})
}
ledgerOpen.value = false
const response = await printedpartsApi.get(item.value.printeditemid)
item.value = response.data.data
} catch (submitError) {
ledgerError.value =
submitError.response?.data?.data?.error?.message ||
submitError.response?.data?.error?.message || 'Submit failed'
} finally {
ledgerSaving.value = false
}
}
function formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
</script>
<style scoped>
.hero-actions { margin-top: 0.75rem; }
.qty-out { color: var(--danger); }
.qty-in { color: var(--success); }
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
</div>
<div class="card form-card">
<div v-if="error" class="error-message">{{ error }}</div>
<form @submit.prevent="save">
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input v-model="form.itemname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Bin location</label>
<input v-model="form.binlocation" type="text" class="form-control"
placeholder="e.g., Bin A3" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="form.itemdescription" type="text" class="form-control"
maxlength="500" placeholder="Brief description shown on the storefront" />
</div>
<div class="form-row">
<div class="form-group">
<label>Low-stock threshold</label>
<input v-model.number="form.lowstockthreshold" type="number" min="0"
class="form-control" />
</div>
<div v-if="isEdit" class="form-group">
<label>Item code</label>
<input :value="itemcode" type="text" class="form-control" disabled />
</div>
</div>
<div class="form-group">
<label>Print notes</label>
<textarea v-model="form.printnotes" class="form-control" rows="3"
placeholder="Material, print time, slicer file path"></textarea>
</div>
<div v-if="isEdit" class="form-group">
<label>Photo</label>
<div class="image-row">
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
<input type="file" accept="image/*" @change="onImagePicked" />
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
@click="removeImage">Remove photo</button>
</div>
</div>
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { printedpartsApi } from '../../api'
import { withBase } from '../../utils/basePath'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const cancelTarget = computed(() =>
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
const form = ref({
itemname: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
const itemcode = ref('')
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (!isEdit.value) return
try {
const response = await printedpartsApi.get(route.params.id)
const item = response.data.data
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
itemcode.value = item.itemcode
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
console.error(loadError)
}
})
async function save() {
saving.value = true
error.value = ''
try {
const payload = { ...form.value }
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)
printeditemid = route.params.id
} else {
const response = await printedpartsApi.create(payload)
printeditemid = response.data.data.printeditemid
}
router.push(`/printedparts/${printeditemid}`)
} catch (saveError) {
error.value = saveError.response?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
try {
const response = await printedpartsApi.uploadImage(route.params.id, file)
imageurl.value = response.data.data.imageurl
} catch (uploadError) {
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
}
}
async function removeImage() {
try {
await printedpartsApi.deleteImage(route.params.id)
imageurl.value = null
} catch (deleteError) {
error.value = 'Could not remove the image'
console.error(deleteError)
}
}
</script>
<style scoped>
.image-row {
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview {
width: 6rem;
height: 6rem;
object-fit: cover;
border-radius: 0.35rem;
border: 1px solid var(--border);
}
.form-hint { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
<div class="header-actions">
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
Print Labels
</router-link>
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
</div>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search code, name, description, bin..."
@input="debouncedSearch"
/>
<label class="lowstock-filter">
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
Low stock only
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th></th>
<th>Code</th>
<th>Name</th>
<th>Quantity</th>
<th>Bin</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.printeditemid"
class="clickable-row"
@click="$router.push(`/printedparts/${item.printeditemid}`)"
>
<td class="thumb-cell">
<img
v-if="item.imageurl"
:src="withBase(item.imageurl)"
:alt="item.itemname"
class="item-thumb"
/>
</td>
<td>{{ item.itemcode || '-' }}</td>
<td>{{ item.itemname }}</td>
<td>
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
{{ item.quantityonhand }}
</span>
</td>
<td>{{ item.binlocation || '-' }}</td>
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" class="empty-state">No printed parts found</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:total-pages="totalPages"
@change="setPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printedpartsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { withBase } from '../../utils/basePath'
const items = ref([])
const loading = ref(true)
const lowstockOnly = ref(false)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (lowstockOnly.value) params.lowstock = 'true'
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printed parts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => setSearch(search.value), 300)
}
</script>
<style scoped>
.item-thumb {
width: 2.2rem;
height: 2.2rem;
object-fit: cover;
border-radius: 0.25rem;
}
.thumb-cell { width: 3rem; }
.truncate-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions { display: flex; gap: 0.5rem; }
.lowstock-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-light);
cursor: pointer;
}
</style>

View File

@@ -1,149 +0,0 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Printedparts' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/printedparts/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Printedparts Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/printedparts/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/printedparts" class="btn btn-secondary">Back to Printedparts</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await printedpartsApi.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading printedparts:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await printedpartsApi.remove(itemId)
router.push('/printedparts')
} catch (error) {
console.error('Error deleting printedparts:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -1,228 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Printedparts' : 'Add Printedparts' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>Printedparts Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await printedpartsApi.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading printedparts:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await printedpartsApi.update(itemId, payload)
} else {
const response = await printedpartsApi.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/printedparts/${redirectId}` : '/printedparts')
} catch (submitError) {
console.error('Error saving printedparts:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/printedparts/${itemId}`)
} else {
router.push('/printedparts')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,142 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Printedparts</h2>
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/printedparts/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No printedparts records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/printedparts/frontend-api-snippet.js) then swap for:
// import { printedpartsApi } from '../../api'
const printedpartsApi = {
list(params = {}) { return api.get('/printedparts', { params }) },
get(itemId) { return api.get(`/printedparts/${itemId}`) },
create(data) { return api.post('/printedparts', data) },
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await printedpartsApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading printedparts:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div>
<div class="page-header">
<h2>3D Printed Parts</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Item code prefix</label>
<input v-model="values.printedparts_code_prefix" type="text"
class="form-control" maxlength="8" />
<p class="field-hint">
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
Changing it does not rename existing items.
</p>
</div>
<div class="form-group">
<label>Default low-stock threshold</label>
<input v-model.number="values.printedparts_default_threshold"
type="number" min="0" class="form-control" />
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
<option value="deny">Deny - refuse badges with no directory match</option>
<option value="allow">Allow - record the SSO with no name</option>
</select>
</div>
<div class="form-group">
<label>Alert shopdb users</label>
<div class="user-picker">
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
<input type="checkbox" :value="String(candidate.userid)"
v-model="selectedUserids" />
<span>{{ candidate.username }}</span>
<span class="user-email">{{ candidate.email }}</span>
</label>
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
</div>
<p class="field-hint">
Selected users receive low-stock alerts at their account email.
</p>
</div>
<div class="form-group">
<label>Additional alert emails</label>
<input v-model="values.printedparts_alert_email" type="text"
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
<p class="field-hint">
Comma-separated. Empty uses the site-wide alert recipients
(Settings &gt; System &gt; Email). Alerts fire once when an item
crosses its threshold; restocking above re-arms.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi, usersApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_default_threshold',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids'
]
const values = ref({
printedparts_code_prefix: '3DP',
printedparts_default_threshold: 5,
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: ''
})
const users = ref([])
const selectedUserids = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'printedparts' })
const rows = response.data.data || []
for (const row of rows) {
if (KEYS.includes(row.key)) values.value[row.key] = row.value
}
values.value.printedparts_default_threshold =
parseInt(values.value.printedparts_default_threshold, 10) || 0
selectedUserids.value = (values.value.printedparts_alert_userids || '')
.split(',').map(id => id.trim()).filter(Boolean)
const usersResponse = await usersApi.list()
users.value = (usersResponse.data.data || []).filter(
candidate => candidate.isactive && candidate.email)
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
for (const key of KEYS) {
await settingsApi.update(key, String(values.value[key] ?? ''))
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.35rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.user-email { color: var(--text-light); font-size: 0.85rem; }
</style>

View File

@@ -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.

View File

@@ -1,18 +1,513 @@
"""Printedparts plugin API routes.
Stage 2 placeholder: the blueprint must import cleanly for plugin discovery
and migrations (the alembic env imports the models package, which pulls in
plugin.py and this module). Real endpoints land in the next stage.
Reads are open (jwt optional) like every list surface; mutations arrive in
later stages with permission gates. The kiosk endpoints (unauthenticated by
explicit decision - see the proposal) also land later.
"""
from flask import Blueprint
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from sqlalchemy import or_
from shopdb.api import success_response
from shopdb.api import (
db,
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
paginate_query,
require_permission,
)
from ..models import PrintedItem
printedparts_bp = Blueprint('printedparts', __name__)
@printedparts_bp.route('/ping', methods=['GET'])
def ping():
"""Liveness probe for the lab: proves the blueprint is registered."""
return success_response({'plugin': 'printedparts', 'status': 'ok'})
@printedparts_bp.route('/items', methods=['GET'])
@jwt_required(optional=True)
def list_items():
"""List printed items, paginated; search + low-stock filter."""
page, per_page = get_pagination_params(request)
query = PrintedItem.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(PrintedItem.isactive == True)
if search := request.args.get('search'):
like = f'%{search}%'
query = query.filter(or_(
PrintedItem.itemcode.ilike(like),
PrintedItem.itemname.ilike(like),
PrintedItem.itemdescription.ilike(like),
PrintedItem.binlocation.ilike(like),
))
if request.args.get('lowstock', '').lower() == 'true':
query = query.filter(
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
query = query.order_by(PrintedItem.itemname)
items, total = paginate_query(query, page, per_page)
return paginated_response(
[item.to_dict() for item in items], page, per_page, total)
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
@jwt_required(optional=True)
def get_item(item_id: int):
"""Get one printed item with its recent transactions."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found',
http_code=404)
data = item.to_dict()
recent = (item.transactions
.order_by(db.desc('transactiondate'))
.limit(25).all())
data['recenttransactions'] = [t.to_dict() for t in recent]
return success_response(data)
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
import glob
import os
from flask import current_app
from werkzeug.utils import secure_filename
from shopdb.api import Setting
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
IMAGE_URL_PREFIX = '/api/printedparts/image/'
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
'binlocation', 'printnotes')
def _imagedir():
return os.path.join(current_app.instance_path, 'printedpartsimages')
def _mint_itemcode(item):
"""Set itemcode from the configured prefix + the flushed row id."""
prefix = Setting.get('printedparts_code_prefix') or '3DP'
item.itemcode = f'{prefix}-{item.printeditemid:04d}'
@printedparts_bp.route('/items', methods=['POST'])
@jwt_required()
@require_permission('printedparts.create')
def create_item():
"""Create a printed item; the itemcode is minted from the row id."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
itemdescription=data.get('itemdescription'),
lowstockthreshold=threshold,
binlocation=data.get('binlocation'),
printnotes=data.get('printnotes'),
quantityonhand=0,
)
db.session.add(item)
db.session.flush() # assigns printeditemid
_mint_itemcode(item)
db.session.commit()
return success_response(item.to_dict(), message='Printed item created',
http_code=201)
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
@jwt_required()
@require_permission('printedparts.edit')
def update_item(item_id: int):
"""Update catalog fields. Quantity moves ONLY through the ledger."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
data = request.get_json() or {}
if 'quantityonhand' in data:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'quantityonhand is ledger-managed; use restock or adjust')
for field in EDITABLE_FIELDS:
if field in data:
setattr(item, field, data[field])
db.session.commit()
return success_response(item.to_dict(), message='Printed item updated')
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
@jwt_required()
@require_permission('printedparts.delete')
def delete_item(item_id: int):
"""Soft-retire an item; its ledger history stays."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
item.isactive = False
db.session.commit()
return success_response(message='Printed item retired')
# --- item image: the models.py upload/serve/delete trio ---------------------
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
@jwt_required()
@require_permission('printedparts.edit')
def upload_item_image(item_id: int):
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
imagedir = _imagedir()
os.makedirs(imagedir, exist_ok=True)
for old in glob.glob(os.path.join(
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
os.remove(old)
filename = secure_filename(f'printeditem-{item_id}{ext}')
upload.save(os.path.join(imagedir, filename))
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
db.session.commit()
return success_response(item.to_dict(), message='Item image uploaded')
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
def serve_item_image(filename):
"""Serve an uploaded item image (public - kiosk and list read it)."""
from flask import send_from_directory
return send_from_directory(_imagedir(), filename)
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
@jwt_required()
@require_permission('printedparts.delete')
def delete_item_image(item_id: int):
"""Clear an item image; delete the file only if this plugin owns it."""
item = db.session.get(PrintedItem, item_id)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
url = item.imageurl or ''
if url.startswith(IMAGE_URL_PREFIX):
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
path = os.path.join(_imagedir(), filename)
if os.path.exists(path):
os.remove(path)
item.imageurl = None
db.session.commit()
return success_response(item.to_dict(), message='Item image removed')
# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock)
from ..models import PrintedItemTransaction
from ..services.badges import BadgeError, resolve_badge
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None):
"""Append a ledger row and move the cached quantity in ONE commit.
The single-commit invariant is what keeps quantityonhand equal to the
ledger sum; every write path must go through here. Fires the low-stock
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.
"""
quantitybefore = item.quantityonhand
item.quantityonhand += quantitychange
db.session.add(PrintedItemTransaction(
printeditemid=item.printeditemid,
transactiontype=transactiontype,
quantitychange=quantitychange,
employeesso=sso,
employeename=name,
reason=reason,
))
db.session.commit()
if (quantitychange < 0
and quantitybefore > item.lowstockthreshold
and item.quantityonhand <= item.lowstockthreshold):
_send_lowstock_alert(item)
def _alert_recipients():
"""Merge selected shopdb users' account emails with the free-text list.
Empty result means fall back to the site-wide alert_recipients."""
from shopdb.api import User
recipients = []
userids = (Setting.get('printedparts_alert_userids') or '').strip()
for rawid in userids.split(','):
rawid = rawid.strip()
if not rawid.isdigit():
continue
user = db.session.get(User, int(rawid))
if user and user.isactive and user.email:
recipients.append(user.email)
extra = (Setting.get('printedparts_alert_email') or '').strip()
recipients.extend(address.strip() for address in extra.split(',')
if address.strip())
# dedupe, order-preserving
return list(dict.fromkeys(recipients))
def _send_lowstock_alert(item):
"""Best-effort email when an item crosses its low-stock threshold.
Recipients: Setting printedparts_alert_email (comma-separated), falling
back to the site's alert_recipients. Never fails the transaction - the
ledger write already committed."""
from shopdb.api import send_email, send_alert
subject = (f'Low stock: {item.itemname} ({item.itemcode}) - '
f'{item.quantityonhand} left')
html = (f'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
f'to <strong>{item.quantityonhand}</strong> '
f'(threshold {item.lowstockthreshold}).</p>'
f'<p>Bin: {item.binlocation or "-"}</p>'
f'<p>Time to print more.</p>')
try:
recipients = _alert_recipients()
if recipients:
send_email(recipients, subject, html)
else:
send_alert(subject, html)
except Exception:
import logging
logging.getLogger(__name__).exception(
'Low-stock alert failed for %s', item.itemcode)
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
@jwt_required()
@require_permission('printedparts.restock')
def restock_item(item_id: int):
"""Add freshly printed stock. Body: {quantity, badge}."""
item = db.session.get(PrintedItem, item_id)
if not item or not item.isactive:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
data = request.get_json() or {}
quantity = data.get('quantity')
if not isinstance(quantity, int) or quantity < 1:
return error_response(ErrorCodes.VALIDATION_ERROR,
'quantity must be a positive integer')
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, 'restock', quantity, sso, name)
return success_response(item.to_dict(), message='Stock added')
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
@jwt_required()
@require_permission('printedparts.restock')
def adjust_item(item_id: int):
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
item = db.session.get(PrintedItem, item_id)
if not item or not item.isactive:
return error_response(ErrorCodes.NOT_FOUND,
f'Printed item {item_id} not found', http_code=404)
data = request.get_json() or {}
quantitychange = data.get('quantitychange')
if not isinstance(quantitychange, int) or quantitychange == 0:
return error_response(ErrorCodes.VALIDATION_ERROR,
'quantitychange must be a non-zero integer')
reason = (data.get('reason') or '').strip()
if not reason:
return error_response(ErrorCodes.VALIDATION_ERROR,
'reason is required for an adjustment')
if item.quantityonhand + quantitychange < 0:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Adjustment would drive stock below zero '
f'(on hand: {item.quantityonhand})')
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, '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/<itemcode>', 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')
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
import csv
import io
from flask import Response
from sqlalchemy import func
def _csv_response(rows, columns, filename):
"""CSV download; local helper because generate_csv is not on the
contract surface (shopdb.api)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([row.get(column, '') for column in columns])
return Response(
output.getvalue(), mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename={filename}'})
@printedparts_bp.route('/reports/stock', methods=['GET'])
@jwt_required(optional=True)
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
ledgerdelta should always be 0; anything else means a write path
bypassed the single-commit rule and needs finding.
"""
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
# string and breaks the delta arithmetic's type.
ledger = {itemid: int(total) for itemid, total in
db.session.query(
PrintedItemTransaction.printeditemid,
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
.group_by(PrintedItemTransaction.printeditemid).all()}
rows = []
for item in PrintedItem.query.filter_by(isactive=True).order_by(
PrintedItem.itemname).all():
rows.append({
'itemcode': item.itemcode,
'itemname': item.itemname,
'binlocation': item.binlocation or '',
'quantityonhand': item.quantityonhand,
'lowstockthreshold': item.lowstockthreshold,
'islowstock': item.islowstock,
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
})
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
'lowstockthreshold', 'islowstock', 'ledgerdelta']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-stock.csv')
return success_response({'columns': columns, 'rows': rows})
@printedparts_bp.route('/reports/consumption', methods=['GET'])
@jwt_required(optional=True)
def report_consumption():
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItem.itemcode,
PrintedItem.itemname,
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.join(PrintedItemTransaction,
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
'quantitytaken': int(taken)}
for code, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-consumption.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
query = (db.session.query(
PrintedItemTransaction.employeesso,
func.max(PrintedItemTransaction.employeename),
func.count(PrintedItemTransaction.transactionid),
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
.filter(PrintedItemTransaction.transactiontype == 'take'))
if days > 0:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
query = query.group_by(PrintedItemTransaction.employeesso)
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
'quantitytaken': int(taken)}
for sso, name, takes, taken in query.all()]
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
if request.args.get('format') == 'csv':
return _csv_response(rows, columns, 'printedparts-by-person.csv')
return success_response({'columns': columns, 'rows': rows, 'days': days})

View File

@@ -5,7 +5,7 @@
"display_name": "3D Printed Parts",
"author": "",
"dependencies": ["employees"],
"core_version": ">=0.11.0,<1.0.0",
"core_version": ">=0.12.0,<1.0.0",
"api_prefix": "/api/printedparts",
"default_enabled": false
}

View File

@@ -51,11 +51,77 @@ class PrintedpartsPlugin(BasePlugin):
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
def get_permissions(self) -> List:
"""RBAC permissions this plugin owns (seeded on install/enable)."""
return [
('printedparts.view', 'View 3D printed parts', 'printedparts'),
('printedparts.create', 'Create printed parts', 'printedparts'),
('printedparts.edit', 'Edit printed parts', 'printedparts'),
('printedparts.delete', 'Retire printed parts', 'printedparts'),
('printedparts.restock', 'Restock and adjust stock counts',
'printedparts'),
]
def get_settings_cards(self) -> List[dict]:
return [
{
'group': '3D Printed Parts',
'to': '/settings/printedparts',
'icon': 'box',
'title': '3D Parts Settings',
'description': 'Item code prefix, default threshold, kiosk '
'badge policy, low-stock alert recipients',
'position': 47,
},
]
def get_reports(self) -> List[dict]:
return [
{
'id': 'printedparts-stock',
'name': '3D Parts Stock',
'description': 'Stock levels with low-stock flags and the '
'cache-vs-ledger reconcile check',
'category': 'inventory',
'endpoint': '/api/printedparts/reports/stock',
},
{
'id': 'printedparts-consumption',
'name': '3D Parts Consumption',
'description': 'Takes per item over a date range',
'category': 'usage',
'endpoint': '/api/printedparts/reports/consumption',
},
{
'id': 'printedparts-by-person',
'name': '3D Parts by Person',
'description': 'Takes grouped by employee',
'category': 'usage',
'endpoint': '/api/printedparts/reports/by-person',
},
]
def get_navigation_items(self) -> List[dict]:
return [
{
'name': '3D Parts',
'icon': 'box',
'route': '/printedparts',
'position': 46,
},
]
def on_install(self, app: Flask) -> None:
with app.app_context():
self._seed_settings()
logger.info('Printedparts plugin installed')
def on_enable(self, app: Flask) -> None:
# Idempotent re-seed so settings added in later versions reach sites
# that installed earlier (enable runs on every upgrade cycle).
with app.app_context():
self._seed_settings()
def _seed_settings(self) -> None:
defaults = [
('printedparts_code_prefix', '3DP', 'string',
@@ -64,6 +130,12 @@ class PrintedpartsPlugin(BasePlugin):
'Default low-stock threshold for new items'),
('printedparts_unknown_badge', 'deny', 'string',
'Kiosk policy when a badge resolves to no employee: allow or deny'),
('printedparts_alert_email', '', 'string',
'Comma-separated low-stock alert recipients; empty uses the '
'site alert_recipients'),
('printedparts_alert_userids', '', 'string',
'Comma-separated shopdb user ids whose account emails receive '
'low-stock alerts'),
]
for key, value, valuetype, description in defaults:
if Setting.get(key) is None:

View File

@@ -0,0 +1 @@
"""Printedparts plugin services."""

View File

@@ -0,0 +1,69 @@
"""Badge resolution for the printedparts plugin.
Same input contract as the USB plugin (deliberately copied, not imported -
cross-plugin imports break the shopdb.api-only contract):
- all digits -> an SSO typed or scanned directly
- 0<digits>BZ -> a physical badge wrapping a PayNo (keyboard-wedge
scanners emit this shape)
- anything else -> unresolvable
Names come from the employees plugin's self-hosted directory, looked up by
SSO. The directory carries no PayNo column, so PayNo badges resolve only when
the wrapped digits are themselves the SSO (true at sites whose badges encode
the SSO); otherwise they fall to the unknown-badge policy.
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
with no directory match; 'allow' records the SSO with an empty name.
"""
import re
from shopdb.api import Setting
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
class BadgeError(ValueError):
"""Raised when a badge cannot be accepted under the site policy."""
def _directory_name(sso):
"""Best-effort display name from the employees plugin directory."""
try:
from plugins.employees.models import DirectoryEmployee
from shopdb.api import db
if sso and str(sso).isdigit():
employee = db.session.get(DirectoryEmployee, int(sso))
if employee:
return f'{employee.firstname} {employee.lastname}'.strip()
except Exception:
pass
return None
def resolve_badge(badge):
"""Return (sso, name) for a scanned badge, enforcing the site policy.
Raises BadgeError with a kiosk-displayable message when the badge shape is
unrecognized or the policy denies an unmatched badge.
"""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
sso = badge
else:
match = _PAYNO_BADGE.match(badge)
if not match:
raise BadgeError('Unrecognized badge format')
sso = match.group(1)
name = _directory_name(sso)
if name is None:
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
if policy != 'allow':
raise BadgeError('Badge not recognized - see the parts team')
return sso, ''
return sso, name

View File

@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
__contract_version__ = '0.11.0'
__contract_version__ = '0.13.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -44,6 +44,7 @@ from shopdb.core.models import (
OperatingSystem,
AssetRelationship,
RelationshipType,
User,
)
# Response + pagination helpers for plugin API blueprints
@@ -78,6 +79,7 @@ from shopdb.core.services.dualpath import (
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
from shopdb.utils.mailer import send_email, send_alert
# CMMC USB check-in/out database (read-write) used by the usb plugin
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
@@ -266,6 +268,9 @@ __all__ = [
'parse_import_datetime',
# Legacy employee directory
'employee_connection',
'send_email',
'send_alert',
'User',
# CMMC USB check-in/out database
'cmmc_usb_connection',
]

View File

@@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# shape as the exempt collector blueprint; the geenforce admin endpoints in
# the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
'geenforce.post_report'}
'geenforce.post_report',
# Deliberately open kiosk write: decrement-only,
# badge-attributed server-side. Decision record in
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
'printedparts.kiosk_take'}
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,242 @@
"""Printedparts ledger + badge tests.
The invariants that make the plugin trustworthy: itemcode minting, the
single-commit cache==ledger rule, the below-zero guard, quantity edits
forced through the ledger, and the badge contract (SSO digits, PayNo
wrap, unknown-badge policy).
"""
import pytest
from shopdb.api import db
from shopdb.core.models import Setting
from plugins.printedparts.models import PrintedItem, PrintedItemTransaction
@pytest.fixture
def item(app, db):
with app.app_context():
row = PrintedItem(itemcode='3DP-9001', itemname='Test clip',
quantityonhand=0, lowstockthreshold=5)
db.session.add(row)
db.session.commit()
yield row.printeditemid
@pytest.fixture
def directory_employee(app):
with app.app_context():
from plugins.employees.models import DirectoryEmployee
if not db.session.get(DirectoryEmployee, 502000001):
db.session.add(DirectoryEmployee(
sso=502000001, firstname='Pat', lastname='Printer'))
db.session.commit()
return '502000001'
def test_create_mints_itemcode(client, auth_headers):
response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'},
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()['data']
assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
assert data['quantityonhand'] == 0
def test_update_refuses_quantity(client, auth_headers, item):
response = client.put(f'/api/printedparts/items/{item}',
json={'quantityonhand': 50}, headers=auth_headers)
assert response.status_code == 400
def test_restock_writes_ledger_and_cache(client, auth_headers, app, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['quantityonhand'] == 10
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all()
assert len(rows) == 1
assert rows[0].transactiontype == 'restock'
assert rows[0].quantitychange == 10
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
assert cached == sum(r.quantitychange for r in rows)
def test_payno_badge_shape_resolves(client, auth_headers, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1,
'badge': f'0{directory_employee}BZ'},
headers=auth_headers)
assert response.status_code == 200
def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item,
directory_employee):
no_reason = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1,
'badge': directory_employee},
headers=auth_headers)
assert no_reason.status_code == 400
below_zero = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1, 'reason': 'test',
'badge': directory_employee},
headers=auth_headers)
assert below_zero.status_code == 400
def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app,
item):
denied = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert denied.status_code == 422
with app.app_context():
Setting.set('printedparts_unknown_badge', 'allow',
valuetype='string', category='printedparts')
db.session.commit()
try:
allowed = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert allowed.status_code == 200
assert allowed.get_json()['data']['quantityonhand'] == 1
finally:
with app.app_context():
Setting.set('printedparts_unknown_badge', 'deny',
valuetype='string', category='printedparts')
db.session.commit()
def test_anonymous_cannot_mutate(client, item):
assert client.post('/api/printedparts/items',
json={'itemname': 'X'}).status_code == 401
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'}).status_code == 401
def test_member_without_permission_gets_403(client, member_headers, item):
"""Authentication alone is not authorization: a role-less user is denied."""
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
headers=member_headers).status_code == 403
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
def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
directory_employee, monkeypatch):
"""One alert when stock CROSSES the threshold downward; restocking above
rearms it; staying below does not re-fire."""
sent = []
import plugins.printedparts.api.routes as printedparts_routes
monkeypatch.setattr(
printedparts_routes, '_send_lowstock_alert',
lambda alerted_item: sent.append(alerted_item.itemcode))
def restock(quantity):
return client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': quantity,
'badge': directory_employee},
headers=auth_headers)
def take(quantity):
return client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee,
'quantity': quantity})
restock(10) # 10 on hand, threshold 5
assert take(3).status_code == 200 # 7: above threshold, no alert
assert sent == []
assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert
assert sent == ['3DP-9001']
assert take(2).status_code == 200 # 2: still below, no re-fire
assert sent == ['3DP-9001']
restock(20) # 22: rearmed
assert take(18).status_code == 200 # 4: crosses again -> second alert
assert sent == ['3DP-9001', '3DP-9001']
def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
item, directory_employee,
monkeypatch):
"""Selected shopdb users' account emails merge with the free-text list,
deduped; inactive users are skipped."""
import shopdb.api as contract_surface
captured = {}
monkeypatch.setattr(contract_surface, 'send_email',
lambda to, subject, html, text=None:
captured.setdefault('to', to) or True)
with app.app_context():
from shopdb.api import User
from werkzeug.security import generate_password_hash
active = User(username='partslead', email='lead@site.test',
passwordhash=generate_password_hash('x'), isactive=True)
inactive = User(username='oldtimer', email='gone@site.test',
passwordhash=generate_password_hash('x'),
isactive=False)
db.session.add_all([active, inactive])
db.session.commit()
Setting.set('printedparts_alert_userids',
f'{active.userid},{inactive.userid}',
valuetype='string', category='printedparts')
Setting.set('printedparts_alert_email',
'extra@site.test, lead@site.test',
valuetype='string', category='printedparts')
db.session.commit()
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
take = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee, 'quantity': 6})
assert take.status_code == 200 # 4 on hand: crossed threshold 5
assert captured['to'] == ['lead@site.test', 'extra@site.test']