printedparts stage 5: the ledger - restock/adjust with badge attribution
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.
This commit is contained in:
@@ -1153,5 +1153,11 @@ export const printedpartsApi = {
|
|||||||
},
|
},
|
||||||
deleteImage(printeditemid) {
|
deleteImage(printeditemid) {
|
||||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<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`"
|
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +102,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="card">Item not found</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -104,6 +134,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { printedpartsApi } from '../../api'
|
import { printedpartsApi } from '../../api'
|
||||||
import { withBase } from '../../utils/basePath'
|
import { withBase } from '../../utils/basePath'
|
||||||
|
import Modal from '../../components/Modal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const item = ref(null)
|
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) {
|
function formatDate(value) {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
|
|||||||
@@ -210,3 +210,80 @@ def delete_item_image(item_id: int):
|
|||||||
item.imageurl = None
|
item.imageurl = None
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(item.to_dict(), message='Item image removed')
|
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/<int:item_id>/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/<int:item_id>/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')
|
||||||
|
|||||||
1
plugins/printedparts/services/__init__.py
Normal file
1
plugins/printedparts/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Printedparts plugin services."""
|
||||||
69
plugins/printedparts/services/badges.py
Normal file
69
plugins/printedparts/services/badges.py
Normal 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
|
||||||
121
tests/test_plugins/test_printedparts_ledger.py
Normal file
121
tests/test_plugins/test_printedparts_ledger.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user