diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md
index 75b53a9..ebf576d 100644
--- a/docs/PLUGIN-LAB-PRINTEDPARTS.md
+++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md
@@ -335,6 +335,18 @@ docs-drift guard again).
4. Test: active user's email + free-text merge deduped, inactive user
skipped (`test_alert_recipients_merge_users_and_freetext`).
+## Stage 14 (extension) - retire/restore in the UI, dashless codes
+
+Field feedback stage: the soft-delete endpoint existed with no button, and
+the site wanted `WJRP0042`, not `WJRP-0042`.
+1. Detail gains Retire (confirm dialog; item leaves the storefront and the
+ kiosk 404s its code, history and label intact) and Restore; the list
+ gains an Include-retired toggle (`?active=false`) with a Retired badge.
+ Restore is its own POST gated by printedparts.delete - PUT deliberately
+ cannot flip isactive.
+2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their
+ codes - itemcode is an immutable label once printed on a bin.
+
---
## Where each pattern lives (cheat sheet)
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 2a00d2b..87b4529 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -1144,6 +1144,9 @@ export const printedpartsApi = {
remove(printeditemid) {
return api.delete(`/printedparts/items/${printeditemid}`)
},
+ restore(printeditemid) {
+ return api.post(`/printedparts/items/${printeditemid}/restore`)
+ },
uploadImage(printeditemid, file) {
const formData = new FormData()
formData.append('file', file)
diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue
index 1bd805e..ceef6bf 100644
--- a/frontend/src/views/printedparts/PrintedItemDetail.vue
+++ b/frontend/src/views/printedparts/PrintedItemDetail.vue
@@ -14,6 +14,7 @@
{{ item.quantityonhand }} on hand
Low stock
+ Retired
- New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}-0042.
+ New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
Changing it does not rename existing items.
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index f44f3bf..409827d 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -91,7 +91,7 @@ def _imagedir():
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}'
+ item.itemcode = f'{prefix}{item.printeditemid:04d}'
@printedparts_bp.route('/items', methods=['POST'])
@@ -159,6 +159,20 @@ def delete_item(item_id: int):
return success_response(message='Printed item retired')
+@printedparts_bp.route('/items//restore', methods=['POST'])
+@jwt_required()
+@require_permission('printedparts.delete')
+def restore_item(item_id: int):
+ """Bring a retired item back; code, photo, and history are intact."""
+ 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 = True
+ db.session.commit()
+ return success_response(item.to_dict(), message='Printed item restored')
+
+
# --- item image: the models.py upload/serve/delete trio ---------------------
@printedparts_bp.route('/items//image', methods=['POST'])
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
index 7835216..ce07b4b 100644
--- a/tests/test_plugins/test_printedparts_ledger.py
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -39,7 +39,7 @@ def test_create_mints_itemcode(client, auth_headers):
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()['data']
- assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
+ assert data['itemcode'] == f"3DP{data['printeditemid']:04d}"
assert data['quantityonhand'] == 0
@@ -240,3 +240,23 @@ def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
assert take.status_code == 200 # 4 on hand: crossed threshold 5
assert captured['to'] == ['lead@site.test', 'extra@site.test']
+
+
+def test_retire_hides_and_restore_returns(client, auth_headers, item):
+ """Retire drops the item from the default list and the kiosk; restore
+ brings it back with history intact."""
+ assert client.delete(f'/api/printedparts/items/{item}',
+ headers=auth_headers).status_code == 200
+
+ listed = client.get('/api/printedparts/items').get_json()['data']
+ assert all(row['printeditemid'] != item for row in listed)
+ kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
+ assert kiosk.status_code == 404
+
+ including = client.get('/api/printedparts/items?active=false')
+ assert any(row['printeditemid'] == item
+ for row in including.get_json()['data'])
+
+ assert client.post(f'/api/printedparts/items/{item}/restore',
+ headers=auth_headers).status_code == 200
+ assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200