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:
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user