diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md
index da2e08b..a6f2c55 100644
--- a/docs/PLUGIN-LAB-PRINTEDPARTS.md
+++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md
@@ -1090,6 +1090,17 @@ failure. Summaries here; complete diffs at the tags.
keypad-driven manual entry (the stage-7 code above is the corrected
version).
+- **17 - the gage-lab asset tag** (`lab-stage-17`): the field team assigns
+ real WJRP asset numbers at the gage lab, so identity split in two: the
+ internal `itemcode` stays auto-minted (stable, encodes the row id) and a
+ new optional UNIQUE `gagelabtag` carries the lab's number (migration
+ 0003). Search covers it; the kiosk resolves scans and bare keypad digits
+ against the numeric tail of EITHER identifier, unique-match only. Lesson:
+ when the real world already numbers things, model their identifier
+ alongside yours instead of fighting over one field. Also in this stage:
+ the print-files table became stacked revision cards after the table
+ forced horizontal scrolling in its column.
+
Post-stage polish (untagged commits): the kiosk launches from the sidebar's
"Displays" section (beside Shopfloor Dashboard / TV Slideshow, plugin-gated,
new tab), and the keypad was restyled into a terminal-style panel after the
diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue
index 335895b..2ccb910 100644
--- a/frontend/src/views/printedparts/PrintedItemDetail.vue
+++ b/frontend/src/views/printedparts/PrintedItemDetail.vue
@@ -47,6 +47,10 @@
Item code
{{ item.itemcode }}
+
+ Gage lab tag
+ {{ item.gagelabtag }}
+
Bin location
{{ item.binlocation || '-' }}
@@ -81,42 +85,30 @@
{{ fileError }}
-
-
-
-
- Rev
- File
- Size
- By
- Note
-
-
-
-
-
- {{ revision.revision }}
-
-
- {{ revision.filename }}
-
- current
-
- {{ formatSize(revision.filesize) }}
- {{ revision.uploadedby }}
- {{ revision.uploadnote || '-' }}
-
- Delete
-
-
-
- No print file uploaded yet
-
-
-
-
+
@@ -344,6 +336,40 @@ function formatDate(value) {
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
-.current-revision td { font-weight: 600; }
+.file-revision-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+.file-revision-list li {
+ position: relative;
+ border: 1px solid var(--border);
+ border-radius: 0.45rem;
+ padding: 0.6rem 5.5rem 0.6rem 0.8rem;
+}
+.file-revision-list li.current-revision { border-color: var(--primary); }
+.file-main {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+.file-name {
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+.file-meta {
+ color: var(--text-light);
+ font-size: 0.85rem;
+ margin-top: 0.2rem;
+}
+.file-delete {
+ position: absolute;
+ top: 0.55rem;
+ right: 0.6rem;
+}
.qty-in { color: var(--success); }
diff --git a/frontend/src/views/printedparts/PrintedItemForm.vue b/frontend/src/views/printedparts/PrintedItemForm.vue
index 10058d0..7f17ee3 100644
--- a/frontend/src/views/printedparts/PrintedItemForm.vue
+++ b/frontend/src/views/printedparts/PrintedItemForm.vue
@@ -32,9 +32,14 @@
-
@@ -81,12 +86,12 @@ const cancelTarget = computed(() =>
const form = ref({
itemname: '',
+ gagelabtag: '',
itemdescription: '',
lowstockthreshold: null,
binlocation: '',
printnotes: ''
})
-const itemcode = ref('')
const imageurl = ref(null)
const saving = ref(false)
const error = ref('')
@@ -99,7 +104,6 @@ onMounted(async () => {
for (const key of Object.keys(form.value)) {
form.value[key] = item[key]
}
- itemcode.value = item.itemcode
imageurl.value = item.imageurl
} catch (loadError) {
error.value = 'Could not load the item'
@@ -115,6 +119,7 @@ async function save() {
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
delete payload.lowstockthreshold
}
+ if (!payload.gagelabtag) payload.gagelabtag = ''
let printeditemid
if (isEdit.value) {
await printedpartsApi.update(route.params.id, payload)
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index 18c3072..97e9654 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -41,6 +41,7 @@ def list_items():
like = f'%{search}%'
query = query.filter(or_(
PrintedItem.itemcode.ilike(like),
+ PrintedItem.gagelabtag.ilike(like),
PrintedItem.itemname.ilike(like),
PrintedItem.itemdescription.ilike(like),
PrintedItem.binlocation.ilike(like),
@@ -103,18 +104,28 @@ def _mint_itemcode(item):
@jwt_required()
@require_permission('printedparts.create')
def create_item():
- """Create a printed item; the itemcode is minted from the row id."""
+ """Create a printed item.
+
+ The internal itemcode is always auto-minted; the OPTIONAL gagelabtag
+ carries the gage lab's assigned WJRP asset number (unique-checked)."""
data = request.get_json() or {}
itemname = (data.get('itemname') or '').strip()
if not itemname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
+ gagelabtag = (data.get('gagelabtag') or '').strip().upper()
+ if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first():
+ return error_response(ErrorCodes.CONFLICT,
+ f'Gage lab tag {gagelabtag} is already in use',
+ http_code=409)
+
threshold = data.get('lowstockthreshold')
if threshold is None:
threshold = int(Setting.get('printedparts_default_threshold') or 5)
item = PrintedItem(
itemname=itemname,
+ gagelabtag=gagelabtag or None,
itemdescription=data.get('itemdescription'),
lowstockthreshold=threshold,
binlocation=data.get('binlocation'),
@@ -143,6 +154,18 @@ def update_item(item_id: int):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'quantityonhand is ledger-managed; use restock or adjust')
+ if 'gagelabtag' in data:
+ gagelabtag = (data.get('gagelabtag') or '').strip().upper()
+ if gagelabtag:
+ clash = PrintedItem.query.filter(
+ PrintedItem.gagelabtag == gagelabtag,
+ PrintedItem.printeditemid != item.printeditemid).first()
+ if clash:
+ return error_response(
+ ErrorCodes.CONFLICT,
+ f'Gage lab tag {gagelabtag} is already in use',
+ http_code=409)
+ item.gagelabtag = gagelabtag or None
for field in EDITABLE_FIELDS:
if field in data:
setattr(item, field, data[field])
@@ -391,16 +414,25 @@ def _kiosk_find_item(itemcode):
Accepts the full code (WJRP0042) or bare digits from the touch keypad
(42 -> prefix + zero-pad), so manual entry never needs letters."""
- itemcode = (itemcode or '').strip()
+ scanned = (itemcode or '').strip().upper()
item = PrintedItem.query.filter(
- PrintedItem.itemcode == itemcode,
+ or_(PrintedItem.itemcode == scanned,
+ PrintedItem.gagelabtag == scanned),
PrintedItem.isactive == True).first()
- if not item and itemcode.isdigit():
- # The digits in a minted code ARE the row id, so id lookup keeps
- # working even for labels printed under an older prefix.
- candidate = db.session.get(PrintedItem, int(itemcode))
- if candidate and candidate.isactive:
- item = candidate
+ if not item and scanned.isdigit():
+ # Bare digits from the touch keypad match the NUMBER inside either
+ # identifier (internal code or gage-lab tag). Small catalog: scan
+ # actives and compare numeric tails; only a UNIQUE match counts.
+ wanted = int(scanned)
+ matches = []
+ for candidate in PrintedItem.query.filter_by(isactive=True).all():
+ for value in (candidate.itemcode, candidate.gagelabtag):
+ tail = ''.join(ch for ch in (value or '') if ch.isdigit())
+ if tail and int(tail) == wanted:
+ matches.append(candidate)
+ break
+ if len(matches) == 1:
+ item = matches[0]
return item
diff --git a/plugins/printedparts/migrations/versions/0003_gagelabtag.py b/plugins/printedparts/migrations/versions/0003_gagelabtag.py
new file mode 100644
index 0000000..3f01115
--- /dev/null
+++ b/plugins/printedparts/migrations/versions/0003_gagelabtag.py
@@ -0,0 +1,27 @@
+"""Add printeditems.gagelabtag: the gage-lab assigned asset tag.
+
+The gage lab issues WJRP asset numbers for printed parts; the internal
+itemcode stays auto-generated, and this optional unique tag carries the
+lab's number. The kiosk resolves scans/typed digits against both.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'printedparts0003gagetag'
+down_revision = 'printedparts0002files'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column('printeditems',
+ sa.Column('gagelabtag', sa.String(length=50), nullable=True))
+ op.create_index('ix_printeditems_gagelabtag', 'printeditems',
+ ['gagelabtag'], unique=True)
+
+
+def downgrade():
+ op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
+ op.drop_column('printeditems', 'gagelabtag')
diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py
index 9f924e1..5f7f6e6 100644
--- a/plugins/printedparts/models/printeditem.py
+++ b/plugins/printedparts/models/printeditem.py
@@ -28,6 +28,8 @@ class PrintedItem(BaseModel):
printeditemid = db.Column(db.Integer, primary_key=True)
itemcode = db.Column(db.String(20), unique=True, index=True,
comment='Generated bin-label code, e.g. 3DP-0042')
+ gagelabtag = db.Column(db.String(50), unique=True, index=True,
+ comment='Gage-lab assigned asset tag, e.g. WJRP0117')
itemname = db.Column(db.String(120), nullable=False)
itemdescription = db.Column(db.String(500))
imageurl = db.Column(db.String(255))
@@ -48,6 +50,7 @@ class PrintedItem(BaseModel):
return {
'printeditemid': self.printeditemid,
'itemcode': self.itemcode,
+ 'gagelabtag': self.gagelabtag,
'itemname': self.itemname,
'itemdescription': self.itemdescription,
'imageurl': self.imageurl,
diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py
index f001ef8..8113a21 100644
--- a/tests/test_plugin_migrations.py
+++ b/tests/test_plugin_migrations.py
@@ -55,7 +55,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
# printedparts is post-cutover: its 0001 really creates its tables.
-EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files'
+EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0003gagetag'
# notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
index 48f3aa3..6f53488 100644
--- a/tests/test_plugins/test_printedparts_ledger.py
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -338,3 +338,32 @@ def test_alert_role_members_receive(client, auth_headers, app, item,
'badge': directory_employee, 'quantity': 6})
assert take.status_code == 200
assert captured['to'] == ['crewone@site.test']
+
+
+def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
+ """The internal code stays auto-minted; the gage-lab tag is optional,
+ unique, searchable, and the kiosk resolves it (exact and bare digits)."""
+ created = client.post('/api/printedparts/items',
+ json={'itemname': 'Gage block holder',
+ 'gagelabtag': 'wjrp0117'},
+ headers=auth_headers)
+ assert created.status_code == 201
+ data = created.get_json()['data']
+ assert data['gagelabtag'] == 'WJRP0117'
+ assert data['itemcode'].startswith('3DP') # internal code untouched
+
+ duplicate = client.post('/api/printedparts/items',
+ json={'itemname': 'Other',
+ 'gagelabtag': 'WJRP0117'},
+ headers=auth_headers)
+ assert duplicate.status_code == 409
+
+ searched = client.get('/api/printedparts/items?search=WJRP0117',
+ headers=auth_headers).get_json()['data']
+ assert len(searched) == 1
+
+ by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117')
+ assert by_tag.status_code == 200
+ by_digits = client.get('/api/printedparts/kiosk/item/117')
+ assert by_digits.status_code == 200
+ assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'