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 }}
-
- - - - - - - - - - - - - - - - - - - - - - - - -
RevFileSizeByNote
{{ revision.revision }} - - {{ revision.filename }} - - current - {{ formatSize(revision.filesize) }}{{ revision.uploadedby }}{{ revision.uploadnote || '-' }} - -
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 @@
-
- - +
+ + +

+ Assigned by the gage lab; optional. The kiosk finds parts by + this tag or by the internal code. +

@@ -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'