printedparts: QR label with gage tag + revision; record taken rev

Label switches from CODE128 to a QR encoding 'TAG|rev' (gage lab tag + latest
print-file revision), so a physical part carries which revision it was printed
from - short payload stays low-version + reliable at 0.5in (margin quiet zone,
EC M, no logo). Item exposes latestrevision; kiosk strips the |rev to resolve
and records the scanned revision on the take (migration 0004 adds
printeditemtransactions.revision) for traceability of which rev was consumed.
Manual entry records a null revision.
This commit is contained in:
cproudlock
2026-07-22 07:52:17 -04:00
parent e85553e5e7
commit baf6862151
6 changed files with 151 additions and 34 deletions

View File

@@ -266,7 +266,8 @@ from ..models import PrintedItemTransaction
from ..services.badges import BadgeError, resolve_badge from ..services.badges import BadgeError, resolve_badge
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None): def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
revision=None):
"""Append a ledger row and move the cached quantity in ONE commit. """Append a ledger row and move the cached quantity in ONE commit.
The single-commit invariant is what keeps quantityonhand equal to the The single-commit invariant is what keeps quantityonhand equal to the
@@ -284,6 +285,7 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None)
employeesso=sso, employeesso=sso,
employeename=name, employeename=name,
reason=reason, reason=reason,
revision=revision,
)) ))
db.session.commit() db.session.commit()
if (quantitychange < 0 if (quantitychange < 0
@@ -413,8 +415,10 @@ def _kiosk_find_item(itemcode):
"""Resolve a scanned or typed code to an active item. """Resolve a scanned or typed code to an active item.
Accepts the full code (WJRP0042) or bare digits from the touch keypad Accepts the full code (WJRP0042) or bare digits from the touch keypad
(42 -> prefix + zero-pad), so manual entry never needs letters.""" (42 -> prefix + zero-pad), so manual entry never needs letters. A label QR
scanned = (itemcode or '').strip().upper() carries 'TAG|rev'; the revision is stripped here (it identifies the physical
part's file rev, not a different item)."""
scanned = (itemcode or '').split('|', 1)[0].strip().upper()
item = PrintedItem.query.filter( item = PrintedItem.query.filter(
or_(PrintedItem.itemcode == scanned, or_(PrintedItem.itemcode == scanned,
PrintedItem.gagelabtag == scanned), PrintedItem.gagelabtag == scanned),
@@ -472,7 +476,19 @@ def kiosk_take():
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422) http_code=422)
_ledger_write(item, 'take', -quantity, sso, name) # Print-file revision the scanned part carried: explicit field, else the
# 'TAG|rev' tail of the scanned code. Recorded for traceability.
revision = data.get('revision')
if revision is None and '|' in (data.get('itemcode') or ''):
tail = data['itemcode'].split('|', 1)[1].strip()
revision = tail if tail.isdigit() else None
if revision is not None:
try:
revision = int(revision)
except (ValueError, TypeError):
revision = None
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
return success_response(item.to_dict(), return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left') message=f'Took {quantity}, {item.quantityonhand} left')

View File

@@ -106,6 +106,7 @@ const doneMessage = ref('')
const submitting = ref(false) const submitting = ref(false)
const manualCode = ref('') const manualCode = ref('')
const manualBadge = ref('') const manualBadge = ref('')
const scannedRevision = ref(null)
const entryInput = ref(null) const entryInput = ref(null)
let resetTimer = null let resetTimer = null
@@ -126,6 +127,9 @@ async function lookupItem(itemcode) {
error.value = '' error.value = ''
const code = (itemcode || '').trim() const code = (itemcode || '').trim()
if (!code) return if (!code) return
// A label QR carries "TAG|rev"; capture the revision, resolve by the TAG part.
const pipe = code.indexOf('|')
scannedRevision.value = pipe >= 0 ? (parseInt(code.slice(pipe + 1), 10) || null) : null
try { try {
const response = await printedpartsApi.kioskItem(code) const response = await printedpartsApi.kioskItem(code)
item.value = response.data.data item.value = response.data.data
@@ -156,7 +160,8 @@ async function submitTake() {
const response = await printedpartsApi.kioskTake({ const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode, itemcode: item.value.itemcode,
badge: badge.value, badge: badge.value,
quantity: parseInt(quantity.value, 10) quantity: parseInt(quantity.value, 10),
revision: scannedRevision.value
}) })
doneMessage.value = response.data.message doneMessage.value = response.data.message
step.value = 'done' step.value = 'done'
@@ -182,6 +187,7 @@ function reset() {
quantity.value = '' quantity.value = ''
manualCode.value = '' manualCode.value = ''
manualBadge.value = '' manualBadge.value = ''
scannedRevision.value = null
error.value = '' error.value = ''
doneMessage.value = '' doneMessage.value = ''
focusEntry() focusEntry()

View File

@@ -4,9 +4,9 @@
<div class="controls"> <div class="controls">
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3> <h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
<p> <p>
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of Each label is one page on 1in x 0.5in roll stock: a QR of the gage lab
the gage lab tag (the internal code is used when a part has no tag), tag plus its latest print-file revision (TAG|rev), so a scanned part
scannable at the parts kiosk. carries which revision it was printed from. Scannable at the parts kiosk.
</p> </p>
<div v-if="loading" class="loading-msg">Loading parts...</div> <div v-if="loading" class="loading-msg">Loading parts...</div>
@@ -43,23 +43,38 @@
<div class="labels-container"> <div class="labels-container">
<div v-for="(label, index) in printLabels" :key="index" class="bin-label"> <div v-for="(label, index) in printLabels" :key="index" class="bin-label">
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg> <img v-if="qrDataUrls[index]" :src="qrDataUrls[index]" class="bin-qr" alt="" />
<div class="bin-code">{{ label.gagelabtag || label.itemcode }}</div> <div class="bin-code">{{ labelText(label) }}</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import JsBarcode from 'jsbarcode' import QRCode from 'qrcode'
import { printedpartsApi } from '@/api' import { printedpartsApi } from '@/api'
const items = ref([]) const items = ref([])
const selectedItems = ref([]) const selectedItems = ref([])
const copies = ref(1) const copies = ref(1)
const loading = ref(true) const loading = ref(true)
const barcodeElements = ref({}) const qrDataUrls = ref({})
// The QR encodes 'TAG|rev' (gage lab tag, or internal code when untagged, plus
// the latest print-file revision) so a scanned part carries which revision it
// was printed from. Short data keeps the QR low-version and reliable at 0.5in.
function labelTag(item) {
return item.gagelabtag || item.itemcode
}
function labelPayload(item) {
return item.latestrevision != null
? `${labelTag(item)}|${item.latestrevision}` : labelTag(item)
}
function labelText(item) {
return item.latestrevision != null
? `${labelTag(item)} rev ${item.latestrevision}` : labelTag(item)
}
onMounted(async () => { onMounted(async () => {
try { try {
@@ -103,27 +118,17 @@ function toggleItem(item) {
} }
} }
function setBarcodeElement(element, index) {
if (element) barcodeElements.value[index] = element
}
watch(printLabels, async labels => { watch(printLabels, async labels => {
await nextTick() const urls = {}
labels.forEach((label, index) => { await Promise.all(labels.map(async (label, index) => {
const element = barcodeElements.value[index] // margin 2 = quiet zone (scannability); EC 'M' keeps modules big for the
if (element) { // short payload on tiny media; no logo overlay on a 0.36in code.
// CODE128 of the gage lab tag (or the internal code when untagged) fits urls[index] = await QRCode.toDataURL(labelPayload(label), {
// 1x0.5in with comfortable scanner tolerance; a QR here would be marginal. margin: 2, errorCorrectionLevel: 'M', width: 160
JsBarcode(element, label.gagelabtag || label.itemcode, {
format: 'CODE128',
displayValue: false,
width: 1.4,
height: 26,
margin: 0
}) })
} }))
}) qrDataUrls.value = urls
}, { deep: true }) }, { deep: true, immediate: true })
function print() { function print() {
window.print() window.print()
@@ -180,7 +185,9 @@ function print() {
background: #fff; background: #fff;
outline: 1px dashed #bbb; outline: 1px dashed #bbb;
} }
.bin-barcode { width: 0.92in; height: 0.3in; } /* Square QR sized to leave room for the text line; pixelated keeps the
modules crisp (no blur) when the data-URL image scales, so it scans. */
.bin-qr { width: 0.36in; height: 0.36in; image-rendering: pixelated; }
.bin-code { .bin-code {
font-size: 6.5pt; font-size: 6.5pt;
font-family: monospace; font-family: monospace;

View File

@@ -0,0 +1,43 @@
"""printedparts: printeditemtransactions.revision (traceability)
Records which print-file revision the physical part carried when it was taken,
captured from the label QR (gage tag + revision). Nullable - manual entry and
pre-QR labels have no revision. Idempotent guard.
Revision ID: printedparts0004txnrev
Revises: printedparts0003gagetag
Create Date: 2026-07-21
"""
from alembic import op
import sqlalchemy as sa
revision = 'printedparts0004txnrev'
down_revision = 'printedparts0003gagetag'
branch_labels = None
depends_on = None
def _has_col(insp):
return any(c['name'] == 'revision'
for c in insp.get_columns('printeditemtransactions'))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'printeditemtransactions' not in insp.get_table_names():
return
if not _has_col(insp):
op.add_column('printeditemtransactions',
sa.Column('revision', sa.Integer(), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'printeditemtransactions' not in insp.get_table_names():
return
if _has_col(insp):
op.drop_column('printeditemtransactions', 'revision')

View File

@@ -46,11 +46,20 @@ class PrintedItem(BaseModel):
def islowstock(self): def islowstock(self):
return self.quantityonhand <= self.lowstockthreshold return self.quantityonhand <= self.lowstockthreshold
@property
def latestrevision(self):
"""Highest print-file revision, or None when the item has no files.
Stamped on the label QR so the physical part carries its source rev."""
from sqlalchemy import func
return db.session.query(func.max(PrintedItemFile.revision)).filter(
PrintedItemFile.printeditemid == self.printeditemid).scalar()
def to_dict(self): def to_dict(self):
return { return {
'printeditemid': self.printeditemid, 'printeditemid': self.printeditemid,
'itemcode': self.itemcode, 'itemcode': self.itemcode,
'gagelabtag': self.gagelabtag, 'gagelabtag': self.gagelabtag,
'latestrevision': self.latestrevision,
'itemname': self.itemname, 'itemname': self.itemname,
'itemdescription': self.itemdescription, 'itemdescription': self.itemdescription,
'imageurl': self.imageurl, 'imageurl': self.imageurl,
@@ -82,6 +91,10 @@ class PrintedItemTransaction(BaseModel):
employeesso = db.Column(db.String(20), nullable=False, index=True) employeesso = db.Column(db.String(20), nullable=False, index=True)
employeename = db.Column(db.String(120)) employeename = db.Column(db.String(120))
reason = db.Column(db.String(255)) reason = db.Column(db.String(255))
# Print-file revision the physical part carried, captured from the label QR
# at take time (null for manual entry / pre-QR labels). Traceability of
# which revision was actually consumed.
revision = db.Column(db.Integer)
transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow, transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
index=True) index=True)
@@ -94,6 +107,7 @@ class PrintedItemTransaction(BaseModel):
'employeesso': self.employeesso, 'employeesso': self.employeesso,
'employeename': self.employeename, 'employeename': self.employeename,
'reason': self.reason, 'reason': self.reason,
'revision': self.revision,
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
} }

View File

@@ -367,3 +367,34 @@ def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
by_digits = client.get('/api/printedparts/kiosk/item/117') by_digits = client.get('/api/printedparts/kiosk/item/117')
assert by_digits.status_code == 200 assert by_digits.status_code == 200
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117' assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee):
"""A label QR payload 'TAG|rev' resolves the item by TAG and records the
revision on the take (traceability of which print-file rev was consumed)."""
with app.app_context():
row = PrintedItem(itemcode='3DP-7000', gagelabtag='WJRP7000',
itemname='Rev part', quantityonhand=10, lowstockthreshold=2)
db.session.add(row)
db.session.commit()
iid = row.printeditemid
take = client.post('/api/printedparts/kiosk/take', json={
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1})
assert take.status_code == 200, take.get_json()
with app.app_context():
txn = PrintedItemTransaction.query.filter_by(
printeditemid=iid, transactiontype='take').first()
assert txn is not None
assert txn.revision == 3
# Manual entry (no |rev) records a null revision, still resolves.
take2 = client.post('/api/printedparts/kiosk/take', json={
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1})
assert take2.status_code == 200
with app.app_context():
last = (PrintedItemTransaction.query
.filter_by(printeditemid=iid, transactiontype='take')
.order_by(PrintedItemTransaction.transactionid.desc()).first())
assert last.revision is None