Skip to content

Asset QR Labels Developer Guide

Ed Mozley edited this page Jul 28, 2026 · 3 revisions

QR Asset Labels β€” Developer Guide

Two identifiers, one short URL, and a page whose primary device is a phone. Shipped as #935.

The analyst-facing page is QR asset labels.


1. πŸ“ The files involved

Colour key: 🧠 shared rule Β· πŸ”Œ API Β· πŸ–₯️ page Β· πŸ—„οΈ schema Β· πŸ”— routing Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
🧠 includes/asset_labels.php The whole rule: schema gate, token mint/resolve, per-company tag availability, and the label URL
πŸ–₯️ asset-management/scan.php Where a scan lands. Mobile-first, with the two edits worth making while stood next to the asset
πŸ–₯️ asset-management/labels.php The print sheet (four label pitches) and the CSV export for a print house
πŸ–₯️ asset-management/assign-tags.php The reverse flow: scan the serial, scan the pre-printed tag, save, repeat
πŸ”Œ api/assets/save_asset_tag.php Sets the tag, enforcing per-company uniqueness
πŸ”Œ api/assets/find_asset.php Exact-match lookup by serial / hostname / tag, for the tagging loop
πŸ”Œ api/assets/get_assets.php Returns asset_tag (behind the schema gate) so the list can show the chip
πŸ–₯️ asset-management/index.php The tag field, Print label, the Ctrl/Shift batch selection, and the Assign-tags link
πŸ”— .htaccess One rewrite: /a/<token> β†’ scan.php
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php assets.asset_tag + assets.qr_token, one index each
🌍 lang/en/asset-management.php, lang/pt-BR/asset-management.php field.asset_tag*, list.*, detail.print_label
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #935

Note what is not there: no PDF library, and no QR library β€” assets/js/qrcode.min.js was already bundled for MFA enrolment, so the codes are drawn client-side with nothing new added and nothing sent to a third-party QR service.


2. πŸ—„οΈ Two identifiers, and why

`asset_tag`  VARCHAR(64) NULL,   -- the human number on the label: "LT0001"
`qr_token`   VARCHAR(64) NULL,   -- what the QR encodes: opaque, install-wide unique
asset_tag qr_token
Read by people machines
Unique per company install-wide
Set by an analyst minted on first print
In the QR? no yes

The question that forces this design: what if two companies both use LT0001? If the QR encoded the tag, it couldn't resolve. If it encoded assets.id, …/a/4711 would invite somebody to try 4712. So it encodes neither.

The token isn't a secret β€” the scan page requires a login and enforces company scope like any other asset read β€” but there is no reason to hand out an enumerable index of the estate to anyone who photographs one label.

⚠️ The trap: per-company uniqueness cannot be a unique index

The obvious schema is UNIQUE (tenant_id, asset_tag). It does not work here.

MySQL treats NULLs as distinct in a unique index, and tenant_id IS NULL means the Default company. So two Default-company assets could both be LT0001 while the index sat there looking like it was guarding them β€” and a unique index that silently doesn't apply is worse than no index at all, because everyone downstream trusts it.

Uniqueness is therefore enforced in assetTagAvailable(), using the null-safe equality operator so the Default company behaves like a named one:

"SELECT COUNT(*) FROM assets WHERE tenant_id <=> ? AND asset_tag = ?"

This is not a new idea in this schema β€” hostname is checked the same way, for the same reason, and freeitsm.sql already said so. The index that is there (idx_assets_tag) is for lookup only, and says so in a comment.

uq_assets_qr_token is unique, safely: the token is install-wide and never NULL once minted.


3. πŸ”— Why the URL is short

RewriteRule ^a/([A-Za-z0-9]+)$ asset-management/scan.php?token=$1 [L,QSA]

Every character in a QR is another module. These print at roughly 15mm square on the side of a laptop that then spends three years being knocked about, so a coarser, more forgiving code matters more than a tidy path. /a/<token> with a 20-character token keeps the code small enough to scan when scuffed.

The absolute URL comes from assetPublicBaseUrl(), which reuses messagingPublicBaseUrl() and its messaging_public_base_url setting rather than adding a second answer to "how does the outside world reach this install?". A label is printed once and lives on a laptop for years, so deriving it from whichever hostname the printing analyst happened to be using would bake that in permanently.

⚠️ Flagged rather than hidden: that setting is named for messaging and is now doing install-wide work. It wants a generic key (reading both, falling back) next time settings are touched.


4. πŸ›‘οΈ The schema gates

Both columns are added by Database Verification, so anything that names them has to survive an install that has pulled the update and not yet run it. assetLabelsSchemaReady() (cached per request) guards:

Caller Degrades to
get_assets.php NULL AS asset_tag β€” critical: naming the column unconditionally would turn the whole asset list into Unknown column
find_asset.php drops the tag from both the SELECT and the WHERE
save_asset_tag.php refuses with "run Database Verification"
scan.php a plain "labels aren't set up yet" page
labels.php the same, instead of a sheet of broken codes

Same lesson as the snooze columns in the ticket list β€” see Snoozing tickets β€” Developer Guide Β§5.


5. πŸ“± scan.php is mobile-FIRST, not mobile-adapted

This is the one page in FreeITSM whose primary device is a phone: you are stood in a store room holding the laptop. So it is a purpose-built narrow surface with its own small stylesheet, not the desktop asset editor put through mobile.css.

That is a deliberate departure from the mobile-friendly rollout's wrap-don't-edit rule, and the reason is simple: that rule exists to protect a desktop layout, and this page has no desktop layout to protect. The rule still applies in full to the Assets module proper.

What it does share is the hard-won detail:

  • 46px touch targets, and 16px inputs β€” under 16px, iOS zooms on focus, the page reflows to desktop width, and mobile rules stop matching (the trap documented in Mobile-Friendly: Techniques Β§3).
  • Pinch-zoom left enabled β€” no user-scalable=no. Reading a battered serial in a dim store room is exactly when someone needs to zoom.
  • Status and location save on change, with no Save button: the failure mode of a forgotten Save on a phone is losing the edit entirely.

Those two writes go through the module's existing update_asset_field.php, so a phone edit gets the same validation, history entry and warranty-calendar sync as a desktop one. A phone edit is not a lesser edit.

The lists are fetched, not queried

scan.php does not query the status and location lists itself. They're scoped by two different rules β€” locations are scoped data (activeTenantFilter), status types are a config list (globals + the company's own, minus its hidden ones via tenant_config_hidden) β€” so the page calls the module's own endpoints, which already implement both. A second copy of either would drift.


6. πŸ–¨οΈ The print sheet

@page plus millimetre units, because millimetres are the unit label stock is sold in and the unit the printer thinks in. No PDF dependency: the browser's print dialog gives the same result with a live preview.

Four pitches are offered (65/40/24/12 per A4). Each label is a flex row of QR + text, break-inside: avoid so one never splits across pages, with dashed cut guides that exist only on screen.

πŸ› A bug worth remembering: the sheet keys were numeric strings ('65', '40'…), which PHP silently casts to integer array keys. A === comparison against the string from $_GET then never matched, so every <option> rendered unselected β€” the browser showed the first one while the grid below used the real choice. A dropdown disagreeing with the page. Compare (string)$k.

The CSV, and a bug that would have reached paper

There's no ITAM standard for handing labels to a print house, but there is a standard process β€” variable data printing β€” so the deliverable is one row per label with the QR payload as a literal URL, not a picture.

πŸ› PHP 8.4 deprecates relying on fputcsv()'s default $escape. With display_errors on, that notice is written into the download β€” an HTML fragment in the middle of a CSV that a print house would merge onto 500 labels. Pass it explicitly; '' also disables backslash escaping, which is what Excel and every CSV reader expects. api/intune/dashboard_drilldown.php had the identical bug and was fixed alongside.


7. πŸ” assign-tags.php β€” the reverse flow

Printing assumes FreeITSM picks the numbers. Companies that buy pre-printed sequential tags need the opposite: the sticker exists, and the database must agree with it.

The loop is built around scanning what is already on the machine β€” the manufacturer's serial barcode β€” because hunting a list for "which laptop is this?" is the slow part of a tagging day. A barcode scanner is a keyboard that ends with Enter, so keydown on Enter is the entire interaction model; no driver, no pairing, no app.

Three deliberate behaviours:

  • find_asset.php matches exactly, never fuzzily. The one genuinely bad outcome is a tag on the wrong machine. Ranking (tag β†’ serial β†’ hostname) is done in PHP over at most five rows, not in SQL β€” ordering placeholders would have to be spliced around the tenant fragment's, and positional binding is strictly left-to-right, which is exactly how the wrong value gets bound six months later.
  • The matched asset is shown before the tag field, so a mis-scan is caught before it matters.
  • A duplicate tag keeps you on the asset rather than resetting: the tag is in your hand and needs a decision.

8. πŸ–±οΈ Batch selection, and two bugs it taught us

Picking several assets mirrors the ticket inbox (#910) rather than inventing an idiom: a plain click still opens the asset, and Ctrl/Shift build a selection on top. No checkboxes, no "selection mode" toggle β€” both change the list for everyone to serve an occasional job.

πŸ› The open asset must be IN the selection. First cut cleared the selection on a plain click, so opening one asset and Ctrl-clicking a second gave you one, not two. Explorer and Outlook treat the highlighted row as selected. The fix seeds the selection inside selectAsset() β€” not the click handler β€” so arriving by deep link behaves the same, and Clear returns to just the open asset rather than to nothing.

πŸ› Shift-click smeared the browser's text selection across the list, and Edge popped its selection mini-menu. One cause: text selection begins on mousedown, so a user-select: none class applied in the click handler is always a beat too late for the first shift-click. preventDefault() on a shift-mousedown inside the list is what actually prevents it β€” and the mini-menu goes with it, because that popup is triggered by having text selected. The ticket inbox had the identical latent bug (#emailList.multi-selecting was doing the too-late thing) and was fixed the same way.


9. βœ… How this was verified

  1. Tokens: minted once and stable on re-call, resolving back to the right asset; unknown and malformed tokens both resolve to null.
  2. Per-company tag uniqueness including the Default company β€” the case the unique index could not have caught: same tag refused within a company, allowed in another, and allowed on the asset that already holds it.
  3. Company scope with positive controls β€” a restricted analyst printing their own company's asset (control) and getting nothing for another's; the same token scanned by a restricted analyst ("not recognised") and by an all-company analyst (the asset), so the block is demonstrably the scope and not a broken token.
  4. The tagging loop driven as a scanner would β€” type, Enter, type, Enter β€” asserting the match, the save, the counter, the reset and focus, a duplicate keeping its place, and an unknown scan.
  5. The selection, driven with real modifier-key events: plain click leaves one selected and the bar hidden; Ctrl-click gives two; Shift spans the block; Clear returns to the open asset. Plus defaultPrevented asserted true for a shift-mousedown inside the list and false both for a plain mousedown and for a shift-mousedown outside it.
  6. The CSV re-checked for HTML leakage after the fputcsv fix.
  7. Pre-upgrade behaviour, and D005 confirming both new endpoints land on Module access: assets.

⚠️ Screenshot gotcha, rediscovered the hard way: on Windows, --window-size=360 does not give a 360px layout β€” the OS clamps the window's minimum width, so the screenshot is cropped, which looks exactly like content overflowing the viewport. It nearly cost a "fix" for a non-bug. Render inside a 360px iframe in a larger window, and measure documentElement.scrollWidth against clientWidth before believing any narrow screenshot. Already recorded in Mobile-Friendly: Techniques Β§8.


10. Extending it

  • Auto-numbering ("next free tag in this company") β€” the availability check is already there; it needs a prefix convention and a per-company counter, and a decision about gaps.
  • Print from the table view β€” labels.php takes ?ids=, so it's a selection UI on table.php, nothing more.
  • Custom label sizes β€” deliberately left out of v1 rather than half-done; the pitch table is the place.
  • Portal scanning (a requester scanning a label to report a fault) β€” would need a decision about what a non-analyst may see, and is a genuinely different feature.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally