diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 5684439..db4dd23 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -1153,5 +1153,11 @@ export const printedpartsApi = {
},
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)
}
}
diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue
index 2c370a3..d14a158 100644
--- a/frontend/src/views/printedparts/PrintedItemDetail.vue
+++ b/frontend/src/views/printedparts/PrintedItemDetail.vue
@@ -19,6 +19,12 @@
{{ item.itemdescription }}
+
+
Edit
@@ -96,6 +102,30 @@
Item not found
+
+
+ {{ ledgerError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,6 +134,7 @@ 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)
@@ -120,6 +151,50 @@ onMounted(async () => {
}
})
+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()
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index 8cb7753..61d5d6b 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -210,3 +210,80 @@ def delete_item_image(item_id: int):
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.
+ """
+ item.quantityonhand += quantitychange
+ db.session.add(PrintedItemTransaction(
+ printeditemid=item.printeditemid,
+ transactiontype=transactiontype,
+ quantitychange=quantitychange,
+ employeesso=sso,
+ employeename=name,
+ reason=reason,
+ ))
+ db.session.commit()
+
+
+@printedparts_bp.route('/items//restock', methods=['POST'])
+@jwt_required()
+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//adjust', methods=['POST'])
+@jwt_required()
+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')
diff --git a/plugins/printedparts/services/__init__.py b/plugins/printedparts/services/__init__.py
new file mode 100644
index 0000000..21758a6
--- /dev/null
+++ b/plugins/printedparts/services/__init__.py
@@ -0,0 +1 @@
+"""Printedparts plugin services."""
diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py
new file mode 100644
index 0000000..81c7f43
--- /dev/null
+++ b/plugins/printedparts/services/badges.py
@@ -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
+- 0BZ -> 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
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
new file mode 100644
index 0000000..21f4e55
--- /dev/null
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -0,0 +1,121 @@
+"""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