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