SOP: Generating & Printing Receiving Labels
Document ID: WMS-REC-005 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Floor staff who need to label received goods for downstream picking and putaway
1. Purpose
This procedure governs how receiving labels are generated and printed on the Zebra ZQ511 mobile printer for goods that arrive without a usable scan barcode — typically the case for cannabis/vape product where vendor barcodes are inconsistent, lot-tracked, or absent. A printed label gives every received unit a Code128 barcode that the WMS can scan during count, putaway, picking, and cycle counts.
Approval (per WMS-REC-003 §4.2) creates InventoryUnit rows whose subsequent operations depend on a scannable barcode. If a SKU has no upc, no barcode field on the variant, and no printed label, every downstream operation falls back to manual entry — slower and higher error rate. Labels close that gap.
⚠ CURRENT-STATE UI GAP (smaller than REC-002 and REC-004). The label generator and print pipeline are fully built — the API endpoints, the ZPL generator, the Capacitor Bluetooth plugin, and even a
PrintLabelButtonReact component are all present. What's missing is the mount point. The receiving session page and the approval page do not currently renderPrintLabelButton, so floor staff have no UI path to print a receiving label. ThePrinterStatusindicator in the navbar works (connect/disconnect), but the Print button never appears next to a line. See §4.4 for the workaround. The fix is roughly an hour of UI work — see §8.
2. Scope
In scope:
- Connecting and pairing the ZQ511 over Bluetooth via the navbar
PrinterStatusbutton - Generating receiving labels via
POST /receiving/:sessionId/lines/:lineId/label(single line) andPOST /receiving/:sessionId/labels(batch / whole session) - The barcode resolution rule (
upc→barcode→sku) and the auto-persistedgeneratedBarcodefield onReceivingLine - The label format itself (2"×1" at 203 DPI, Code128, the field layout)
- The current-state workaround until the print button is mounted on receiving pages
Out of scope:
- Location/bin labels (zone, aisle, rack labels) — see WMS-INV-004 §4.4
- Product labels for floor-count / new-SKU mapping — see WMS-INV-003 (uses a different endpoint)
- Order pick-bin labels — see WMS-PICK-002
- Customer return intake labels — see WMS-RET-001
- Shipping labels (carrier labels) — see WMS-SHIP-001 (different printer, different format)
3. Roles & permissions
API enforcement: the two label endpoints (
/lines/:lineId/labeland/labels) check authentication only. Any authenticated user can generate label ZPL. Whether they can print depends on whether their device has the Capacitor Bluetooth plugin (Android-native build only — see §5.3).
| Role | Generate label ZPL | Send to printer |
|---|---|---|
| READONLY | ✓ | ✓ (if paired) |
| STAFF | ✓ | ✓ (if paired) |
| MANAGER | ✓ | ✓ (if paired) |
| ADMIN | ✓ | ✓ (if paired) |
| SUPER_ADMIN | ✓ | ✓ (if paired) |
Receiving labels are not a security-sensitive operation. The role table is permissive across the board.
4. Procedures
4.1 Pairing the ZQ511 to a TC22
Use when: First time on a device, the printer was unpaired, or you've moved to a different printer.
Prerequisites:
- ZQ511 powered on, paper loaded, Bluetooth enabled, in range (~10 m line of sight).
- TC22 has Bluetooth enabled and the WMS Android app installed.
- You have permission to pair on the TC22 (Android Settings, Location enabled — required for BLE discovery on Android 12+).
Steps:
- Open the WMS app on the TC22. The navbar shows a printer icon — red
Xfor disconnected, green check for connected. - Tap the printer icon.
- The app calls
discoverPrinters()via theZebraPrintCapacitor plugin. A bottom sheet appears with discovered printers. - If exactly one printer is found, the app auto-connects to it.
- If multiple are found, tap the one labeled with your station's printer (typically
XXZQ511*followed by a serial). The status flips toconnectedwith a green check. - The MAC address is persisted in
Capacitor Preferencesunder keyzebra_printer_macso the next session auto-reconnects.
Status states (visible in the navbar icon and in the picker sheet):
| Status | Icon | Meaning |
|---|---|---|
disconnected | Red X | No printer paired or last connection lost |
connecting | Spinner | Currently establishing Bluetooth |
connected | Green check | Ready to print |
printing | Spinner | A ZPL job is being sent |
error | Red X + toast | Last operation failed; toast shows reason |
⚠ Bluetooth pairing is per-device, per-OS-build. A web-browser session of the WMS (not the Capacitor APK) cannot print —
Capacitor.isNativePlatform()returnsfalseand theZebraPrintplugin returnsnull. IfPrinterStatusshows permanently red on what looks like the right device, you're probably on the web app, not the Android APK. Open the installed app icon, not the browser shortcut.
4.2 Printing a single line label (target flow — UI not mounted today)
This describes the intended UX. As of release, no receiving page mounts the
PrintLabelButtoncomponent. Use §4.4 for the workaround until it ships.
Use when: A specific received SKU on the current session needs a printed barcode — vendor sent unlabeled stock, the existing barcode is damaged, or you need extra copies to label individual units in a case.
Prerequisites:
- Printer is
connectedper §4.1. - The line exists in the session and you know which line.
Steps (target):
- From the session page (
/receiving/session/:sessionId), expand the line. - Tap the Print Label button (compact: printer icon; full: button labeled
Print Label). Both are rendered byPrintLabelButton. - The button calls
POST /receiving/:sessionId/lines/:lineId/labelwith optional{ copies: 1..10 }(default 1). - The API returns ZPL. The Capacitor plugin sends it to the connected ZQ511 via
sendZPL. - The button shows a green check for 3 seconds on success, or a red error message for 4 seconds on failure.
What the API does:
- Resolves the barcode using
resolveBarcode(variant):variant.upc(trimmed, if non-empty)variant.barcode(trimmed, if non-empty)variant.sku(trimmed)
- If the line's
generatedBarcodeis null, persists the resolved value toReceivingLine.generatedBarcode. From this point forward, every other operation on this line uses the same barcode value. - Generates ZPL via
generateReceivingLabel({ productName, sku, barcode, poReference, quantity, vendor?, lotNumber?, expiryDate? }). Ifcopies > 1, usesgenerateBatchLabels(Array(min(copies, 10)).fill(labelData))— batch print is hard-capped at 10 copies per call. - Returns
{ zpl, barcode, productName, sku }.
Common errors:
| HTTP | API message | What it means |
|---|---|---|
| 404 | "Receiving line not found" | lineId doesn't exist or doesn't belong to sessionId. Refresh. |
| 500 | "Failed to generate label" | Server error in ZPL generation (rare). Check API logs. |
| (UI) | "No label data returned" | API succeeded but zpl field was empty. Retry. |
| (UI) | "No printer connected" | The Capacitor Bluetooth state is disconnected. Tap the printer icon to reconnect. |
| (UI) | "Print failed" (generic) | The ZPL bytes did not transmit. Check printer is on, has paper, and is in range. |
4.3 Batch labels for a whole session (target flow — no UI client today)
Use when: You want to pre-print labels for every line on a PO before counting begins, so the labels are ready to apply as you count.
Prerequisites:
- Printer is
connectedper §4.1. - Session exists and has line items.
Steps (target):
- (No UI exists for this today.) The endpoint
POST /receiving/:sessionId/labelsexists with optional{ lineIds?: string[] }body to filter to specific lines. Manual call only. - The API returns
{ zpl, count }— a single ZPL document containing all labels concatenated. - The Capacitor plugin sends the whole ZPL to the printer in one call. The printer prints each label in sequence.
What the API does:
- Loads all line items (or only the supplied
lineIds) ordered bysku ASC. - For every line missing
generatedBarcode, runsresolveBarcodeand persists the result. This happens in a singleprisma.$transaction([...updates])so partial failures roll back. - Builds a
ReceivingLabelData[]array and callsgenerateBatchLabels(labels). - Returns
{ zpl, count }. Thecountis the number of lines included.
Common errors:
| HTTP | API message | What it means |
|---|---|---|
| 404 | "Session not found" | Session ID is wrong. Refresh. |
| 500 | "Failed to generate labels" | Server error in ZPL or in the generated-barcode update transaction. |
4.4 Current-state workaround for printing labels
Use when: §4.2 / §4.3 don't work because the Print Label button is not mounted on the session or approval pages today.
Two options, in order of preference:
Option A — print location labels via floor-count (works today, slightly off-purpose):
If the goods arriving need a bin location label (you're staging them in a fresh bin and want the bin barcoded), use WMS-INV-003 (Floor count & location discovery) to print a location label. This won't put a SKU/barcode on the goods themselves, but it ensures the staging bin is scannable.
Option B — short-term API-direct call:
If you must label the units themselves before §4.2 ships:
- Pair the printer per §4.1. Confirm
PrinterStatusshowsconnected. - From any developer console (DevTools while on a session page, or curl from an admin laptop with the right cookie), call:
Get thePOST /receiving/{sessionId}/lines/{lineId}/labelBody: { "copies": <n>, max 10 }
zplresponse. - Send the ZPL to the printer via the Capacitor plugin. There is no convenient UI for this on a TC22 unless a developer adds a quick print form.
This is friction-heavy. Until §4.2 is mounted, most floor staff should rely on the existing variant barcode/upc fields and only flag SKUs that genuinely have no scannable barcode for a manager to print labels for via Option B.
Until the button mounts: when a SKU arrives without a usable barcode, count it manually per WMS-REC-001 §4.3, file the issue in #warehouse-ops so a manager can either (a) print labels via Option B post-hoc and re-label the stock in the receiving zone before putaway, or (b) flag the variant for the buyer to push back on the vendor.
⚠ The auto-generated barcode persists on first label-API call. Even via Option B's developer-direct call, the first call to
/lines/:lineId/labelwritesgeneratedBarcodeto the row. From then on, every subsequent count, putaway move, and picking scan resolves to that value via the §5.1 resolution rule. So a single Option B call per line is enough — you don't need to re-call to "lock in" the barcode.
5. Reference
5.1 Barcode resolution rule (verbatim from resolveBarcode)
For a given variant on a receiving line, the barcode used in the printed label and persisted to generatedBarcode is the first non-empty value in this order:
variant.upc— trimmedvariant.barcode— trimmedvariant.sku— trimmed (always non-empty by schema constraint)
Once generatedBarcode is set on the ReceivingLine, all subsequent label, scan, and event operations for that line use the persisted value. Updating the variant's upc or barcode later does not retroactively change the receiving session's barcode — that's intentional and is what makes the label scannable forever for this PO's units.
5.2 Label format (Zebra ZQ511, 2"×1", 203 DPI)
The ZPL document for a single receiving label:
^XA
^LT0 ; no top offset
^FO0,5^A0N,24,24^FB406,1,0,C^FD<NAME>^FS ; product name centered, font 24
^FO0,30^FB406,1,0,C^BY<MW>,3,50^BCN,50,Y,N,N^FD<BARCODE>^FS ; Code128 50-dot tall, MW = scaled module width
^FO10,110^A0N,20,20^FD<SKU>^FS ; SKU bottom-left, font 20
^FO330,110^A0N,20,20^FDx<QTY>^FS ; quantity bottom-right
^XZ
Fields and constraints:
| Field | Source | Truncation / sanitize |
|---|---|---|
productName | ReceivingLine.productName | Truncated to 28 chars, ^ and ~ stripped, non-printable stripped |
sku | ReceivingLine.sku | Truncated to 18 chars, sanitized |
barcode | ReceivingLine.generatedBarcode (or fresh resolution if null) | Sanitized; module width auto-scales to fit 406-dot label width |
quantity | ReceivingLine.quantityCounted (or quantityExpected if 0) | Number prefixed x |
vendor, lotNumber, expiryDate | Session/line | Currently not rendered on the label — passed to the generator but the template omits them. See §8. |
The label is 2"×1" (406×203 dots at 203 DPI). The barcode is 50 dots tall (~0.25"). This is tuned for hand-held scan distances of 4–18". For longer-range case-pack scanning, increase the ^BCN,50,... height in zpl.ts to 100 and reprint — but the label height is fixed at 1" so a 100-dot barcode will dominate the label.
5.3 Native vs. web platform
The ZQ511 is reachable only from a Capacitor Android build of the WMS (Capacitor.isNativePlatform() === true). The web app on a desktop browser, the same web app served at app.hq.team from an iPhone, or any non-native client returns null from getPlugin() and cannot send ZPL to the printer.
Practically:
- The Android APK installed on a TC22 — works.
- The Android APK installed on a personal Android phone — works (can pair to any ZQ511 in range; useful for ad-hoc reprints).
- Chrome/Safari on a desktop or laptop — does not work.
- Chrome on a tablet — does not work (web).
The PrinterStatus icon in the navbar will appear permanently red on web platforms; this is by design.
5.4 Related SOPs
- WMS-REC-001 — Counting (where labeled goods get scanned)
- WMS-REC-003 — Approval (creates the inventory units that benefit from being labeled)
- WMS-REC-004 — Putaway (where labeled goods get scanned again into bins)
- WMS-INV-003 — Floor count & location discovery (uses a different label endpoint for location/SKU labels)
- WMS-INV-004 — Location management (uses
generateLocationLabelfromzpl.ts) - WMS-PICK-001 — Picking (where labeled units get scanned at outbound)
6. Audit & compliance
Label generation has no audit log entries today. The /lines/:lineId/label and /labels endpoints don't write to audit_logs. The only persistent side effect is ReceivingLine.generatedBarcode being set on first call. Reprints leave no trace.
This is a small gap — labels are physical artifacts; the audit story is "did the goods get labeled" (visible in the warehouse) more than "who pressed print." But for cannabis/vape regulatory reporting, the state regulator may ask "were these specific lots labeled before they left receiving?" If yes is required, add a LABELS_PRINTED audit row with the lineIds in the same fashion as receiving session events.
Manager weekly review (current-state):
- Cannabis/vape-product receivings (lot-tracked SKUs) — check that physical labels are visible on the receiving zone before putaway. No system query helps here.
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
PrinterStatus icon stays red after tapping connect | Bluetooth permission denied on TC22, or printer is off | TC22 Settings → Apps → WMS → Permissions → grant Location and Nearby Devices. Power-cycle the printer. Retry. |
PrinterStatus shows green but print fails with "No printer connected" | Stale connection state; printer dropped Bluetooth without notifying app | Tap the printer icon, tap Disconnect, then Reconnect. |
| ZPL prints but the barcode is unreadable | Module width too small for the data length | The auto-scaler in zpl.ts (scaledBarcode) handles most cases. For a SKU >18 chars, the barcode may be too dense. Switch the variant to use a shorter barcode field via product editing, then reprint. |
| Label prints but truncates the product name | Names >28 chars are truncated to "26 chars + .." in truncate() | This is by design for the 2"×1" label. If you need full product name, redesign the label template (4"×2") and update zpl.ts. |
| Vendor / lot / expiry doesn't show on the label | Current generateReceivingLabel template doesn't render those fields, even though the API passes them | See §8 — engineering item. |
"Failed to generate labels" (HTTP 500) on batch print | Most likely the prisma.$transaction([...updates]) for generatedBarcode failed (e.g., a stale line was deleted) | Refresh the session and retry. If it persists, IT — there's a session integrity issue. |
| Reprint produces a different barcode | First call set generatedBarcode; later calls should return the persisted value | Should not happen. If it does, check whether the variant's upc/barcode was edited between calls — the persisted generatedBarcode should override regardless. Escalate as a possible bug. |
| Bluetooth keeps disconnecting | TC22 power-saver killing the BT connection, or printer auto-off after inactivity | TC22 Settings → Battery → WMS → Unrestricted. Set the printer's auto-off to 60+ minutes (per the printer's own UI). |
8. Escalation
- Mount
PrintLabelButtonon the receiving session page: this is the highest-leverage receiving feature ship after WMS-REC-002 and WMS-REC-004. The component exists, works, and is one import + one render away from being usable. Probably an hour of work plus a manager test on a TC22. - Surface batch print on the session page: a single button "Print labels for all unlabeled lines" calling
POST /receiving/:sessionId/labels. Adds maybe two hours. - Render lot/expiry/vendor on the label: the API passes the fields but the ZPL template doesn't draw them. A 2"×1" label is small but lot+expiry would fit if
productNameis given a tighter truncation. Seezpl.tsgenerateReceivingLabeland the layout comment block above it. - Add audit logging: if cannabis/vape regulatory reporting requires it, add a
LABELS_PRINTEDrow per/labelscall with thelineIdsarray. - Web/desktop print path: not currently supported; the Capacitor plugin is Android-native only. If desktop print is wanted, the cleanest path is Zebra Browser Print over the network, which would require a separate codepath alongside the Capacitor plugin.
- Printer permanently offline / Bluetooth troubleshooting failed: IT — the ZQ511 may need a firmware update or factory reset.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | [DATE] | [NAME] | Initial release. Documents the receiving label generation and print pipeline grounded in apps/api/src/routes/receiving.routes.ts (/lines/:lineId/label, /labels), apps/api/src/lib/zpl.ts (resolveBarcode, generateReceivingLabel, generateBatchLabels, the 2"×1" 203-DPI format), apps/web/src/lib/zebra-print.ts (Capacitor plugin), and apps/web/src/components/receiving/PrintLabel.tsx (PrinterStatus, PrintLabelButton). Documents the current-state UI gap: PrinterStatus is mounted in the navbar (AppLayout.tsx) but PrintLabelButton is not mounted on any receiving page. Documents the barcode-resolution rule, the auto-persist of generatedBarcode on first label call, the 10-copy hard cap, and the native-only Capacitor constraint. Provides §4.4 workaround until the button is mounted on the session page. |